Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[3주차 기본/심화 과제] 🍚 점메추 🍚  #7

Merged
merged 27 commits into from
Feb 13, 2024
Merged

Conversation

simeunseo
Copy link
Member

@simeunseo simeunseo commented Nov 9, 2023

배포링크

✨ 구현 기능 명세

🌱 기본 조건

  • 기본조건1

✅ 선택 과정은 총 3단계입니다. ( 3개 → 3개 → 2개)

✅ 아이템은 총 18개 이상입니다. (3 x 3 x 2 = 18)

위는 “최소”기준이며 그 이상의 개수는 가능합니다.

  • 기본조건2

✅ 전역상태관리 라이브러리, context 사용 금지 ❌

✅ Router 사용 금지 ❌


🧩 기본 과제

  1. 추천 종류 선택
    • 취향대로 추천 / 랜덤 추천 중 선택합니다.
    • 선택시 다음화면으로 넘어갑니다.

[취향대로 추천]

  1. 답변 선택

    • 호버시 스타일 변화가 있습니다.
    • 클릭시(선택시) 스타일 변화가 있습니다.
  2. 이전으로, 다음으로(결과보기) 버튼

    • 아무것도 선택되지 않았을 시 버튼을 비활성화 시킵니다.

      → 눌러도 아무 동작 X

      → 비활성화일 때 스타일을 다르게 처리합니다.

    • 이전으로 버튼을 누르면 이전 단계로 이동합니다.

    • 다음으로 / 결과보기 버튼을 누르면 다음 단계로 이동합니다.

    • 버튼 호버시 스타일 변화가 있습니다.

  3. 결과

    • 선택한 정보들에 맞는 결과를 보여줍니다.

[ 랜덤 추천 ]

  1. 숫자 카운트 다운
    • 3 → 2 → 1 숫자 카운트 다운 후 결과를 보여줍니다.
    • 추천 결과는 반드시 랜덤으로 지정합니다.

[ 공통 ]

  1. 결과 화면
    • 다시하기 버튼

      → 랜덤추천이면 랜덤 추천 start 화면으로, 취향대로 추천이면 취향대로 추천 start 화면으로 돌아갑니다.

      → 모든 선택 기록은 리셋됩니다.


🌠 심화 과제

  1. theme + Globalstyle 적용

    • 전역으로 스타일을 사용할 수 있도록 적용해보세요
  2. 애니메이션

    • 랜덤 추천 - 카운트다운에 효과를 넣어서 더 다채롭게 만들어주세요!
  3. 헤더

    • 처음으로 버튼

      → 추천 종류 선택 화면일시 해당 버튼이 보이지 않습니다.

      → 처음 추천 종류 선택 화면으로 돌아갑니다.

      → 모든 선택 기록은 리셋됩니다.

[ 취향대로 추천 ]

  1. 단계 노출

    • 3단계의 진행 단계를 보여줍니다.
  2. 이전으로 버튼

    • 이전으로 돌아가도 선택했던 항목이 선택되어 있습니다.
  • 6. useReducer , useMemo , useCallback 을 사용하여 로직 및 성능을 최적화합니다.


💎 PR Point

  • funnel 구조 사용

    • 토스에서 소개한 useFunnel이라는 커스텀훅이 있는데요! 이 라이브러리를 사용한 건 아니고, 이 방식에서 착안하여 이번 과제를 해결했습니다. 일련의 스텝에 대한 상태와 각 스텝에서 발생하는 선택지에 대한 상태를 하나의 페이지 아래에서 한번에 관리해야 이전, 다음 버튼을 선택했을 때 간편하게 상태를 저장해서 보여줄 수 있기 때문입니다.
    • 제가 구현한 funnel은 다음과 같은 구조로 이루어져 있습니다.
      ├── funnel
         ├── FunnelBody.tsx
         ├── FunnelLayout.tsx
         └── FunnelTitle.tsx
      FunnelLayout이 한 페이지의 전체적인 모든 것을 담고 있고, 이의 자식으로 존재하는 FunnelBody와 FunnelTitle이 FunnelLayout에서 생성된 state인 step에 따라서 변경됩니다.
    스크린샷 2023-11-10 오후 11 12 37 스크린샷 2023-11-10 오후 11 12 41
    • FunnelLayout.tsx
      const FunnelLayout = () => {
      const [step, setStep] = useState<number>(0);
      const [input, setInput] = useState<inputState>({
          recommendType: undefined,
          country: undefined,
          ingredients: undefined,
          broth: undefined,
      });
      
      return (
          <>
          <FunnelLayoutWrapper>
              <Header setStep={setStep} setInput={setInput} input={input} />
              <FunnelTitle step={step} input={input} />
              <FunnelBody step={step} setStep={setStep} input={input} setInput={setInput} />
          </FunnelLayoutWrapper>
          </>
      );
      };
      넘어가는 step과 사용자의 일련의 입력을 최상단에서 관리하기 위해, FunnelLayout에서 이 state를 정의해줍니다. 그리고 필요한 state를 자식 컴포넌트에 prop으로 넘겨줍니다.
    • FunnelBody.tsx
      FunnelBody에서는 각 step별 제목을 제외한 모든 컴포넌트(버튼들)를 렌더링합니다. prop으로 넘겨받은 step에 따라서 각 step에 해당하는 컴포넌트를 조건부로 렌더링하는데, 이 각각의 단계들을 다른 파일로 (First.tsx, Second.tsx ... 등) 분리할까 하다가, 괜히 depth만 깊어지는 것 같아서 그냥 한 파일에 모두 넣어버렸습니다. 가독성과 depth 줄이기 중에 어떤 걸 선택해야 좋은 걸까요?
  • 공통 컴포넌트

    • 각 step마다 공통으로 쓰이는 1. 선택지 버튼 2. 단계 버튼 을 공통 컴포넌트화 하여 사용했습니다.
    • stepBtn.tsx
      function StepBtn({ children, type, onClick }: StepBtnProps) {
          return (
              <StepBtnWrapper $type={type} onClick={onClick}>
              {children}
              </StepBtnWrapper>
          );
      }
      시작하기, 이전으로, 다음으로, 다시하기, 결과보기 에 해당하는 StepBtn입니다. 모두 같은 디자인을 가지고 있으며, 다음 handle 함수를 통해 onClick이 처리됩니다.
      const handleStepBtn = (type: '시작하기' | '이전으로' | '다음으로' | '결과보기' | '다시하기') => {
          switch (type) {
          case '시작하기':
              setCount(3);
              input.recommendType === '취향' ? setStep(1) : (setSelectedMenu(selectMenu(input)), setStep(4));
              break;
          case '이전으로':
              setStep(step - 1);
              break;
          case '다음으로':
              setStep(step + 1);
              break;
          case '결과보기':
              setStep(4);
              setSelectedMenu(selectMenu(input));
              break;
          case '다시하기':
              setStep(0);
              setCount(3);
              setInput({
              recommendType: input.recommendType,
              country: undefined,
              ingredients: undefined,
              broth: undefined,
              });
              break;
          }
      };
    • SelectBtn.tsx
      function SelectBtn({ children, type, onClick }: SelectBtnProps) {
          return (
              <SelectBtnWrapper $type={type} onClick={onClick}>
              {children}
              </SelectBtnWrapper>
          );
      }
      취향/랜덤과 각 단계의 선택지를 고를 때 공통적으로 사용되는 SelectBtn 입니다. 이에 대한 onClick을 처리하는 handle 함수는 다음과 같은데, 지금보니 StepBtn을 처리할 때 처럼 한 함수로 묶어서 switch문으로 분기할 걸 그랬네요..? 일단 시간이 없어서 PR부터 적고...
      const handleRecommendType = (recommendType?: recommendType) => {
          setInput((prev: inputState) => {
          return { ...prev, recommendType: recommendType };
          });
      };
      
      const handleCountry = (country?: country) => {
          setInput((prev: inputState) => {
          return { ...prev, country: country };
          });
      };
      
      const handleIngredients = (ingredients?: ingredients) => {
          setInput((prev: inputState) => {
          return { ...prev, ingredients: ingredients };
          });
      };
  • 선택지에 따른 알맞는 결과물 도출 로직

    const convertInputType: { [key: string]: string } = {
      한식: 'K',
      일식: 'J',
      중식: 'C',
      : 'R',
      : 'N',
      '고기/해물': 'M',
      국물시러: 'X',
      국물조아: 'O',
      };
    
      export const convertStringToName: { [key: string]: string } = {
      KRX: '비빔밥',
      KRO: '국밥',
      KNX: '비빔냉면',
      KNO: '칼국수',
      KMX: '삼겹살',
      KMO: '꽃게탕',
      JRX: '텐동',
      JRO: '명란오차즈케',
      JNX: '마제소바',
      JNO: '라멘',
      JMX: '돈카츠',
      JMO: '모츠나베',
      CRX: '새우볶음밥',
      CRO: '해물누룽지탕',
      CNX: '짜장면',
      CNO: '짬뽕',
      CMX: '깐풍기',
      CMO: '마라탕',
      };
    
      export const selectMenu = (input: inputState) => {
      let finalString = '';
    
      switch (input.recommendType) {
          case '취향':
          if (input.country && input.ingredients && input.broth) {
              finalString += convertInputType[input.country];
              finalString += convertInputType[input.ingredients];
              finalString += convertInputType[input.broth];
              return finalString;
          }
          break;
          case '랜덤':
          finalString += ['K', 'J', 'C'][Math.floor(Math.random() * 3)];
          finalString += ['R', 'N', 'M'][Math.floor(Math.random() * 3)];
          finalString += ['O', 'X'][Math.floor(Math.random() * 2)];
          return finalString;
          default:
          return finalString;
      }
      };
    스크린샷 2023-11-10 오후 11 24 08

    이 부분이 고민이 많이 된 것 같습니다. 좀 요상한 방법인 것 같긴 한데, 저는 마치 MBTI 유형을 계산하듯이, 선택지 각각을 하나의 알파벳으로 정의하고 사용자의 선택을 string으로 이어붙여서 하나의 유형(?)을 만들었습니다. 그리고 이 일련의 문자열에 해당하는 이미지와 이에 매칭하는 음식 이름을 가져옴으로써 결과를 도출했습니다.


🥺 소요 시간, 어려웠던 점

  • 12h
  • CountDown을 구현하는게 가장 어려웠습니다. 특히, 처음으로 버튼을 눌러도 CountDown이 초기화되지 않는 문제가 있었는데, setCountDown의 위치를 옮기고 처음으로 버튼을 눌렀을 때 setCountDown을 사용하여 이를 3으로 초기화함으로써 해결했습니다.
  • vercel 배포를 하고나니 이미지 경로가 제대로 설정이 안되는 문제가 있었어요 ㅠㅠ 방법을 찾아보다가 일단 이미지 경로를 public 폴더에 위치시킴으로서 해결하긴 했는데... 다들 동적 이미지 경로를 어떻게 설정했는지 궁금합니다.

🌈 구현 결과물

배포링크

@simeunseo simeunseo self-assigned this Nov 10, 2023
@simeunseo simeunseo marked this pull request as ready for review November 10, 2023 14:25
@simeunseo simeunseo changed the title Week3/#1 [3주차 기본/심화 과제 ] 🍚 점메추 🍚  Nov 10, 2023
@simeunseo simeunseo changed the title [3주차 기본/심화 과제 ] 🍚 점메추 🍚  [3주차 기본/심화 과제] 🍚 점메추 🍚  Nov 11, 2023
Comment on lines 48 to 68
const handleStepBtn = (type: '이전으로' | '다음으로' | '결과보기' | '다시하기') => {
switch (type) {
case '이전으로':
setStep(step - 1);
break;
case '다음으로':
setStep(step + 1);
break;
case '결과보기':
setStep(4);
setSelectedMenu(selectMenu(input));
break;
case '다시하기':
setStep(0);
setInput({
recommendType: input.recommendType,
country: undefined,
ingredients: undefined,
broth: undefined,
});
break;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헐 한 함수 내에서 switch문 이용해서 구현하니깐 엄청 깔끔하고 좋은 것 같아요!! 😍

Comment on lines 8 to 21
const FunnelLayout = () => {
const [step, setStep] = useState<stepState>(0);
const [recommendType, setRecommendType] = useState<recommendTypeState>(undefined);

return (
<>
<FunnelLayoutWrapper>
<FunnelTitle step={step} />
<FunnelBody step={step} recommendType={recommendType}></FunnelBody>
</FunnelLayoutWrapper>
</>
);
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 스텝 상태를 어디에서 저장하고 관리해야하는지 너무 어려웠고 실제로 하다가 꼬이기도 했는데 은서님 구조 너무 깔끔하고 좋아요!! 제 코드가 부끄러워집니다..😞

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컴포넌트 분리도 너무 야무져요...

Comment on lines +20 to +21
? '국물 있는거, 없는거?'
: '넌 오늘 이걸 먹어라'}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네..먹겠습니...ヲヲヲヲ너무 단호박🎃

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switch문으로 빼주는 것도 좋겠당

Comment on lines +3 to +55
const convertInputType: { [key: string]: string } = {
한식: 'K',
일식: 'J',
중식: 'C',
밥: 'R',
면: 'N',
'고기/해물': 'M',
국물시러: 'X',
국물조아: 'O',
};

export const convertStringToName: { [key: string]: string } = {
KRX: '비빔밥',
KRO: '국밥',
KNX: '비빔냉면',
KNO: '칼국수',
KMX: '삼겹살',
KMO: '꽃게탕',
JRX: '텐동',
JRO: '명란오차즈케',
JNX: '마제소바',
JNO: '라멘',
JMX: '돈카츠',
JMO: '모츠나베',
CRX: '새우볶음밥',
CRO: '해물누룽지탕',
CNX: '짜장면',
CNO: '짬뽕',
CMX: '깐풍기',
CMO: '마라탕',
};

export const selectMenu = (input: inputState) => {
let finalString = '';

switch (input.recommendType) {
case '취향':
if (input.country && input.ingredients && input.broth) {
finalString += convertInputType[input.country];
finalString += convertInputType[input.ingredients];
finalString += convertInputType[input.broth];
return finalString;
}
break;
case '랜덤':
finalString += ['K', 'J', 'C'][Math.floor(Math.random() * 3)];
finalString += ['R', 'N', 'M'][Math.floor(Math.random() * 3)];
finalString += ['O', 'X'][Math.floor(Math.random() * 2)];
return finalString;
default:
return finalString;
}
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헐 요런 방식 너무 신박해요,,,😮 PR에 이 부분 고민 많이 하셨다고 하셔서 제가 구현한 방법 한 번 적어볼게용!
저는 처음에 먼저 객체를 만들고 그 객체에 선택값을 넣어줬습니다! 그리고 결과 보여줄 때는 선택값에 맞는 메뉴만 필터링해주었어요!

const [options, setOption] = useState({
    country: '',
    main: '',
    soup: '',
  });
const menu = MENU_LIST.filter((item) => {
      return (
        item.country === props.options.country && item.main === props.options.main && item.soup === props.options.soup
      );
    });


const fonts = {
Title1: css`
font-family: 'DOSPilgiMedium';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

글씨체 넘 마음에 들어요 ㅋㅋㅋ 멘트랑 찰떡

Comment on lines +75 to +203
</>
),
1: (
<>
<SelectBtnsWrapper>
<SelectBtn
onClick={() => handleCountry('한식')}
type={input.country === '한식' ? 'selected' : undefined}>
한식
</SelectBtn>
<SelectBtn
onClick={() => handleCountry('일식')}
type={input.country === '일식' ? 'selected' : undefined}>
일식
</SelectBtn>
<SelectBtn
onClick={() => handleCountry('중식')}
type={input.country === '중식' ? 'selected' : undefined}>
중식
</SelectBtn>
</SelectBtnsWrapper>
<StepBtnsWrapper>
<StepBtn onClick={() => handleStepBtn('이전으로')}>이전으로</StepBtn>
<StepBtn onClick={() => handleStepBtn('다음으로')} type={input.country || 'disabled'}>
다음으로
</StepBtn>
</StepBtnsWrapper>
</>
),
2: (
<>
<SelectBtnsWrapper>
<SelectBtn
onClick={() => handleIngredients('밥')}
type={input.ingredients === '밥' ? 'selected' : undefined}>
</SelectBtn>
<SelectBtn
onClick={() => handleIngredients('면')}
type={input.ingredients === '면' ? 'selected' : undefined}>
</SelectBtn>
<SelectBtn
onClick={() => handleIngredients('고기/해물')}
type={input.ingredients === '고기/해물' ? 'selected' : undefined}>
고기/해물
</SelectBtn>
</SelectBtnsWrapper>
<StepBtnsWrapper>
<StepBtn onClick={() => handleStepBtn('이전으로')}>이전으로</StepBtn>
<StepBtn onClick={() => handleStepBtn('다음으로')} type={input.ingredients || 'disabled'}>
다음으로
</StepBtn>
</StepBtnsWrapper>
</>
),
3: (
<>
<SelectBtnsWrapper>
<SelectBtn
onClick={() => handleBroth('국물시러')}
type={input.broth === '국물시러' ? 'selected' : undefined}>
국물 시러
</SelectBtn>
<SelectBtn
onClick={() => handleBroth('국물조아')}
type={input.broth === '국물조아' ? 'selected' : undefined}>
국물 조아
</SelectBtn>
</SelectBtnsWrapper>
<StepBtnsWrapper>
<StepBtn onClick={() => handleStepBtn('이전으로')}>이전으로</StepBtn>
<StepBtn
onClick={() => handleStepBtn('결과보기')}
type={input.broth === undefined ? 'disabled' : undefined}>
결과보기
</StepBtn>
</StepBtnsWrapper>
</>
),
4: (
<>
{count > 0 && input.recommendType === '랜덤' ? (
<CountDown count={count} setCount={setCount} />
) : (
selectedMenu && (
<ResultWrapper>
<ImgWrapper src={`${selectedMenu}.jpeg`} alt={convertStringToName[selectedMenu]} />
<SelectedMenuWrapper>✱{convertStringToName[selectedMenu]}✱</SelectedMenuWrapper>
<StepBtn onClick={() => handleStepBtn('다시하기')}>다시하기</StepBtn>
</ResultWrapper>
)
)}
</>
),
}[step]
}
</>
);
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분 코드가 길어져서 혹시 컴포넌트 분리를 하는 건 오떨까용??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 근데 depth 때문에 한 파일에 넣은 거였군요..ㅜ

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 분리하는거 찬성합니다! 뎁스가 그렇게 치명적이지도 않고, 특히 step 1,2,3은 UI와 로직 구조가 반복되어서 공통 컴포넌트로 분리하면 너무 좋을 것 같아요!

Copy link
Member

@qwp0 qwp0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 너무 깔끔하고 특히 funnel 구조는 처음 알게되었는데 설명도 잘 써주셔서 많이 배워갑니당! 너무 고생하셨어요 🥰 심화까지 갓벽. 제가 타스 무지랭이라 코드를 완벽하게 이해하지는 못했지만 열심히 리뷰 남겼보았습니다..ㅎ 그리고 저도 컴포넌트 분리할 때 가독성이랑 depth 때문에 고민 많이 했었는데 너무 어려운 것 같아요..저희 같이 고민해봐요ㅜㅜ 이번 과제도 홧팅!

Comment on lines +20 to +21
? '국물 있는거, 없는거?'
: '넌 오늘 이걸 먹어라'}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switch문으로 빼주는 것도 좋겠당

? '국물 있는거, 없는거?'
: '넌 오늘 이걸 먹어라'}
</TitleWrapper>
<StepWrapper>{input.recommendType === '취향' && step > 0 && step < 4 && step + ' / 3'}</StepWrapper>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<StepWrapper>{input.recommendType === '취향' && step > 0 && step < 4 && step + ' / 3'}</StepWrapper>
{input.recommendType === '취향' && step > 0 && step < 4 && <StepWrapper>{`${step} / 3`}</StepWrapper>}

렌더링 자체를 결정할 수 있도록 이렇게는 어떤가용?

Comment on lines +75 to +203
</>
),
1: (
<>
<SelectBtnsWrapper>
<SelectBtn
onClick={() => handleCountry('한식')}
type={input.country === '한식' ? 'selected' : undefined}>
한식
</SelectBtn>
<SelectBtn
onClick={() => handleCountry('일식')}
type={input.country === '일식' ? 'selected' : undefined}>
일식
</SelectBtn>
<SelectBtn
onClick={() => handleCountry('중식')}
type={input.country === '중식' ? 'selected' : undefined}>
중식
</SelectBtn>
</SelectBtnsWrapper>
<StepBtnsWrapper>
<StepBtn onClick={() => handleStepBtn('이전으로')}>이전으로</StepBtn>
<StepBtn onClick={() => handleStepBtn('다음으로')} type={input.country || 'disabled'}>
다음으로
</StepBtn>
</StepBtnsWrapper>
</>
),
2: (
<>
<SelectBtnsWrapper>
<SelectBtn
onClick={() => handleIngredients('밥')}
type={input.ingredients === '밥' ? 'selected' : undefined}>
</SelectBtn>
<SelectBtn
onClick={() => handleIngredients('면')}
type={input.ingredients === '면' ? 'selected' : undefined}>
</SelectBtn>
<SelectBtn
onClick={() => handleIngredients('고기/해물')}
type={input.ingredients === '고기/해물' ? 'selected' : undefined}>
고기/해물
</SelectBtn>
</SelectBtnsWrapper>
<StepBtnsWrapper>
<StepBtn onClick={() => handleStepBtn('이전으로')}>이전으로</StepBtn>
<StepBtn onClick={() => handleStepBtn('다음으로')} type={input.ingredients || 'disabled'}>
다음으로
</StepBtn>
</StepBtnsWrapper>
</>
),
3: (
<>
<SelectBtnsWrapper>
<SelectBtn
onClick={() => handleBroth('국물시러')}
type={input.broth === '국물시러' ? 'selected' : undefined}>
국물 시러
</SelectBtn>
<SelectBtn
onClick={() => handleBroth('국물조아')}
type={input.broth === '국물조아' ? 'selected' : undefined}>
국물 조아
</SelectBtn>
</SelectBtnsWrapper>
<StepBtnsWrapper>
<StepBtn onClick={() => handleStepBtn('이전으로')}>이전으로</StepBtn>
<StepBtn
onClick={() => handleStepBtn('결과보기')}
type={input.broth === undefined ? 'disabled' : undefined}>
결과보기
</StepBtn>
</StepBtnsWrapper>
</>
),
4: (
<>
{count > 0 && input.recommendType === '랜덤' ? (
<CountDown count={count} setCount={setCount} />
) : (
selectedMenu && (
<ResultWrapper>
<ImgWrapper src={`${selectedMenu}.jpeg`} alt={convertStringToName[selectedMenu]} />
<SelectedMenuWrapper>✱{convertStringToName[selectedMenu]}✱</SelectedMenuWrapper>
<StepBtn onClick={() => handleStepBtn('다시하기')}>다시하기</StepBtn>
</ResultWrapper>
)
)}
</>
),
}[step]
}
</>
);
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 분리하는거 찬성합니다! 뎁스가 그렇게 치명적이지도 않고, 특히 step 1,2,3은 UI와 로직 구조가 반복되어서 공통 컴포넌트로 분리하면 너무 좋을 것 같아요!

@simeunseo simeunseo merged commit 7a466dc into main Feb 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants