Repository: sopt-makers/sopt-playground-frontend
Pull request #1790: fix: 타임캡솝 QA 반영
Author: login: seong-hui
Labels: name: size/XL
Created: 2025-03-24T18:56:29Z | Merged: 2025-03-26T17:46:46Z

### 🤫 쉿, 나한테만 말해줘요. 이슈넘버
- close #1787

### 🧐 어떤 것을 변경했어요~?
<!-- 실제로 변경한 사항을 설명해주세요.-->
- [x] TimecapsopTag enum을 기반으로 하는 태그 키를 사용하도록 구조 변경
  - 서버와 논의 후 기존의 value(한글)이 아닌 key(영어)에 해당 하는 값으로 요청을 보내는 것으로 수정하기로 결정해서 해당 부분을 수정했어요
- [x] 타임캡솝 제출 로직 수정
- [x] complete의 PlaygroundGuideModal에서 onClose실행 시` / `로 이동하도록 변경
- [x] DEBUG 환경에서만 타임캡솝 삭제하기 버튼 추가
- [x] TimecapsopSubmitModal bottomSheet모달로 수정
- [x]  Playground 카드 클릭 시 히스토리 상태 갱신 및 페이지 이동 로직 추가

### 🤔 그렇다면, 어떻게 구현했어요~?
<!-- 실제로 구현한 로직에 대해 설명해주세요.-->
- Tag 인터페이스에 key 필드 추가하여 TAG 상수 리스트 수정했어요.
- TimecapsopSubmitModal에서 selectedTag 상태와 onClickTag 함수 로직을 enum key 기준으로 처리하도록 변경했어요.
- 타임캡솝 메세지를 제출할 때 기존에는 메세지 확인 모달이 떴지만 해당 모달이 안뜨고 바로 api 요청이 실행되도록 수정했어요.

### ❤️‍🔥 당신이 생각하는 PR포인트, 내겐 매력포인트.
<!-- 해당 PR에서 논의가 필요한 사항을 적어주세요. -->

### 📸 스크린샷, 없으면 이것 참,, 섭섭한데요?

https://github.com/user-attachments/assets/ae825a8e-fb58-4c61-af79-c569ad02a367



Linked issue #1787: fix: 타임캡솝 QA 반영
## ✨ 설명하기

## ✅ To Do List

- [ ] 타임캡솝 tag를 key값으로 보내는 형식으로 수정
- [ ] 타임캡솝 보내기 클릭 로직 수정


Diff:
diff --git a/src/api/endpoint/resolution/deleteTimecapsop.ts b/src/api/endpoint/resolution/deleteTimecapsop.ts
new file mode 100644
index 000000000..825dc9940
--- /dev/null
+++ b/src/api/endpoint/resolution/deleteTimecapsop.ts
@@ -0,0 +1,27 @@
+import { useMutation } from '@tanstack/react-query';
+import { useQueryClient } from '@tanstack/react-query';
+import z from 'zod';
+
+import { createEndpoint } from '@/api/typedAxios';
+
+export const deleteResolution = createEndpoint({
+  request: () => ({
+    method: 'delete',
+    url: `api/v1/resolution`,
+  }),
+  serverResponseScheme: z.unknown(),
+});
+
+export const useDeleteResolutionMutation = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationKey: ['deleteResolution'],
+    mutationFn: async () => {
+      const response = await deleteResolution.request();
+      return response;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['getResolutionValidation'] });
+    },
+  });
+};
diff --git a/src/api/endpoint/resolution/postResolution.ts b/src/api/endpoint/resolution/postResolution.ts
index bdba6381a..6f58739c9 100644
--- a/src/api/endpoint/resolution/postResolution.ts
+++ b/src/api/endpoint/resolution/postResolution.ts
@@ -7,7 +7,7 @@ import { TimecapsopTag } from '@/components/resolution/constants';
 
 export interface ResolutionRequestBody {
   content: string;
-  tags: TimecapsopTag[];
+  tags: (keyof typeof TimecapsopTag)[];
 }
 
 export const postResolution = createEndpoint({
diff --git a/src/components/common/Banner/WelcomeBanner/index.tsx b/src/components/common/Banner/WelcomeBanner/index.tsx
index 13bf9f4f5..b44dc0d09 100644
--- a/src/components/common/Banner/WelcomeBanner/index.tsx
+++ b/src/components/common/Banner/WelcomeBanner/index.tsx
@@ -77,16 +77,15 @@ const WelcomeBanner = ({ isLastGeneration }: WelcomeBannerProp) => {
                       </Text>
                     </ResolutionButton>
                   </LoggingClick>
-                  {isOpenResolutionModal && (
-                    <TimecapsopSubmitModal
-                      onClose={onCloseResolutionModal}
-                      userName={name ?? '나'}
-                      onSuccess={() => {
-                        onNewRegistration();
-                        onOpenPlaygroundGuideModal();
-                      }}
-                    />
-                  )}
+                  <TimecapsopSubmitModal
+                    onClose={onCloseResolutionModal}
+                    userName={name ?? '나'}
+                    onSuccess={() => {
+                      onNewRegistration();
+                      onOpenPlaygroundGuideModal();
+                    }}
+                    isOpen={isOpenResolutionModal}
+                  />
                   {isOpenPlaygroundGuideModal && (
                     <PlaygroundGuideModal
                       isAlreadyRegistration={isAlreadyRegistration}
diff --git a/src/components/common/BottomSheet/ModalBottomSheet.tsx b/src/components/common/BottomSheet/ModalBottomSheet.tsx
new file mode 100644
index 000000000..d488e3716
--- /dev/null
+++ b/src/components/common/BottomSheet/ModalBottomSheet.tsx
@@ -0,0 +1,73 @@
+import styled from '@emotion/styled';
+import { colors } from '@sopt-makers/colors';
+import { ComponentProps, FC, PropsWithChildren } from 'react';
+import Sheet from 'react-modal-sheet';
+
+import IconModalClose from '@/public/icons/icon-modal-close.svg';
+
+export interface BottomSheetProps extends PropsWithChildren<ComponentProps<typeof Sheet>> {
+  onClose: () => void;
+}
+
+export const ModalBottomSheet: FC<BottomSheetProps> = (props) => {
+  const { children, isOpen, onClose, ...restProps } = props;
+
+  return (
+    <CustomSheet isOpen={isOpen} onClose={onClose} detent='content-height' {...restProps}>
+      <Sheet.Container>
+        <Sheet.Header>
+          <StyledCloseButton onClick={onClose}>
+            <StyledIconClose />
+          </StyledCloseButton>
+        </Sheet.Header>
+        <Sheet.Content>{children}</Sheet.Content>
+      </Sheet.Container>
+      <Sheet.Backdrop onTap={onClose} />
+    </CustomSheet>
+  );
+};
+
+const CustomSheet = styled(Sheet)`
+  .react-modal-sheet-backdrop {
+    display: flex;
+    position: fixed;
+    top: 0;
+    left: 0;
+    justify-content: center;
+    background-color: ${colors.grayAlpha800}!important;
+    width: 100%;
+    height: 100%;
+  }
+
+  .react-modal-sheet-container {
+    border-radius: 14px !important;
+    background-color: ${colors.gray900}!important;
+    width: 100%;
+    overflow-x: hidden !important;
+    overflow-y: scroll !important;
+  }
+
+  .react-modal-sheet-header {
+    display: flex;
+  }
+
+  .react-modal-sheet-content {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+  }
+`;
+
+const StyledCloseButton = styled.button`
+  display: flex;
+  position: absolute;
+  top: 22px;
+  right: 22px;
+  align-items: center;
+  justify-content: center;
+  z-index: 3;
+  cursor: pointer;
+  padding: 4px;
+`;
+
+const StyledIconClose = styled(IconModalClose)``;
diff --git a/src/components/common/HomePopup/index.tsx b/src/components/common/HomePopup/index.tsx
index 02ba3969d..861ec6440 100644
--- a/src/components/common/HomePopup/index.tsx
+++ b/src/components/common/HomePopup/index.tsx
@@ -109,16 +109,15 @@ export const HomePopup = () => {
         </StBackground>
       )}
 
-      {isOpenResolutionModal && (
-        <TimecapsopSubmitModal
-          onClose={onCloseResolutionModal}
-          userName={name ?? '나'}
-          onSuccess={() => {
-            onNewRegistration();
-            onOpenPlaygroundGuideModal();
-          }}
-        />
-      )}
+      <TimecapsopSubmitModal
+        onClose={onCloseResolutionModal}
+        userName={name ?? '나'}
+        onSuccess={() => {
+          onNewRegistration();
+          onOpenPlaygroundGuideModal();
+        }}
+        isOpen={isOpenResolutionModal}
+      />
       {isOpenPlaygroundGuideModal && (
         <PlaygroundGuideModal isAlreadyRegistration={isAlreadyRegistration} onClose={onClosePlaygroundGuideModal} />
       )}
diff --git a/src/components/resolution/constants.ts b/src/components/resolution/constants.ts
index 6d016eec4..208cf7384 100644
--- a/src/components/resolution/constants.ts
+++ b/src/components/resolution/constants.ts
@@ -7,6 +7,7 @@ export enum TimecapsopTag {
 }
 
 export interface Tag {
+  key: keyof typeof TimecapsopTag;
   value: TimecapsopTag;
   image: {
     default: string;
@@ -17,6 +18,7 @@ export interface Tag {
 
 export const TAG: Tag[] = [
   {
+    key: 'PRODUCT_RELEASE',
     value: TimecapsopTag.PRODUCT_RELEASE,
     image: {
       default: '/icons/icon-release-default.svg',
@@ -25,6 +27,7 @@ export const TAG: Tag[] = [
     },
   },
   {
+    key: 'NETWORKING',
     value: TimecapsopTag.NETWORKING,
     image: {
       default: '/icons/icon-networking-default.svg',
@@ -33,6 +36,7 @@ export const TAG: Tag[] = [
     },
   },
   {
+    key: 'COLLABORATION_EXPERIENCE',
     value: TimecapsopTag.COLLABORATION_EXPERIENCE,
     image: {
       default: '/icons/icon-cooperation-default.svg',
@@ -41,6 +45,7 @@ export const TAG: Tag[] = [
     },
   },
   {
+    key: 'STARTUP',
     value: TimecapsopTag.STARTUP,
     image: {
       default: '/icons/icon-startup-default.svg',
@@ -49,6 +54,7 @@ export const TAG: Tag[] = [
     },
   },
   {
+    key: 'SKILL_UP',
     value: TimecapsopTag.SKILL_UP,
     image: {
       default: '/icons/icon-skill-default.svg',
diff --git a/src/components/resolution/delete/index.tsx b/src/components/resolution/delete/index.tsx
new file mode 100644
index 000000000..737fb376b
--- /dev/null
+++ b/src/components/resolution/delete/index.tsx
@@ -0,0 +1,28 @@
+import styled from '@emotion/styled';
+import { Button } from '@sopt-makers/ui';
+
+import { useDeleteResolutionMutation } from '@/api/endpoint/resolution/deleteTimecapsop';
+
+const TimecapsopDelteButton = () => {
+  const { mutate } = useDeleteResolutionMutation();
+
+  const handleClick = () => {
+    if (confirm('정말로 삭제하시겠습니까?')) {
+      mutate();
+    }
+  };
+
+  return (
+    <DelteBtn size='sm' onClick={handleClick}>
+      타임캡솝 삭제하기
+    </DelteBtn>
+  );
+};
+
+const DelteBtn = styled(Button)`
+  position: absolute;
+  right: 10px;
+  bottom: 10px;
+`;
+
+export default TimecapsopDelteButton;
diff --git a/src/components/resolution/submit/PlaygroundGuideModal.tsx b/src/components/resolution/submit/PlaygroundGuideModal.tsx
index fdb435118..566cefb4d 100644
--- a/src/components/resolution/submit/PlaygroundGuideModal.tsx
+++ b/src/components/resolution/submit/PlaygroundGuideModal.tsx
@@ -1,13 +1,15 @@
 import styled from '@emotion/styled';
 import { colors } from '@sopt-makers/colors';
 import { fonts } from '@sopt-makers/fonts';
-import Link from 'next/link';
+import { useRouter } from 'next/router';
 import { ReactNode } from 'react';
 
 import Modal from '@/components/common/Modal';
 import Text from '@/components/common/Text';
 import { ModalProps } from '@/components/members/detail/MessageSection/Modal';
+import TimecapsopDelteButton from '@/components/resolution/delete';
 import { cards } from '@/components/resolution/submit/constants/cards';
+import { DEBUG } from '@/constants/env';
 import { MB_BIG_MEDIA_QUERY, MOBILE_MEDIA_QUERY } from '@/styles/mediaQuery';
 import { zIndex } from '@/styles/zIndex';
 
@@ -23,6 +25,7 @@ interface CardProps {
   icon: ReactNode;
   button: string;
   href: string;
+  onClose: () => void;
 }
 
 const PlaygroundGuideModal = ({ isAlreadyRegistration, ...props }: PlaygroundGuideModalProps) => {
@@ -51,18 +54,29 @@ const PlaygroundGuideModal = ({ isAlreadyRegistration, ...props }: PlaygroundGui
             icon={card.icon}
             button={card.button}
             href={card.href}
+            onClose={props.onClose}
           />
         ))}
       </CardWrapper>
+      {DEBUG && <TimecapsopDelteButton />}
     </StyledModal>
   );
 };
 
 export default PlaygroundGuideModal;
 
-const Card = ({ name, description, color, hover, icon, button, href }: CardProps) => {
+const Card = ({ name, description, color, hover, icon, button, href, onClose }: CardProps) => {
+  const router = useRouter();
+
+  const handleClick = async (e: React.MouseEvent) => {
+    e.preventDefault();
+    await router.replace('/', undefined, { shallow: true });
+    router.push(href);
+    onClose();
+  };
+
   return (
-    <StyledCard color={color} hover={hover} href={href}>
+    <StyledCard color={color} hover={hover} href={href} onClick={handleClick}>
       <Description typography='SUIT_14_SB' color={colors.black} lineHeight={20}>
         {description}
       </Description>
@@ -141,7 +155,7 @@ const CardWrapper = styled.section`
   margin-top: 20px;
 `;
 
-const StyledCard = styled(Link)<{ color: string; hover: string }>`
+const StyledCard = styled.a<{ color: string; hover: string }>`
   display: flex;
   flex-direction: column;
   gap: 16px;
diff --git a/src/components/resolution/submit/TimecapsopSubmitModal.tsx b/src/components/resolution/submit/TimecapsopSubmitModal.tsx
index e15e41155..05849bd83 100644
--- a/src/components/resolution/submit/TimecapsopSubmitModal.tsx
+++ b/src/components/resolution/submit/TimecapsopSubmitModal.tsx
@@ -1,179 +1,50 @@
 import styled from '@emotion/styled';
-import { yupResolver } from '@hookform/resolvers/yup';
 import { colors } from '@sopt-makers/colors';
-import { IconAlertCircle } from '@sopt-makers/icons';
-import { TextArea } from '@sopt-makers/ui';
-import { FC, useState } from 'react';
-import { Controller, useForm } from 'react-hook-form';
-import * as yup from 'yup';
+import { useRouter } from 'next/router';
+import { playgroundLink } from 'playground-common/export';
+import { FC } from 'react';
 
-import RHFControllerFormItem from '@/components/common/form/RHFControllerFormItem';
-import Loading from '@/components/common/Loading';
+import { ModalBottomSheet } from '@/components/common/BottomSheet/ModalBottomSheet';
 import Modal from '@/components/common/Modal';
-import Text from '@/components/common/Text';
-import { ModalProps } from '@/components/members/detail/MessageSection/Modal';
-import { TAG, TimecapsopTag } from '@/components/resolution/constants';
-import { useConfirmResolution } from '@/components/resolution/submit/useConfirmResolution';
-import { MOBILE_MAX_WIDTH, MOBILE_MEDIA_QUERY } from '@/styles/mediaQuery';
+import Responsive from '@/components/common/Responsive';
+import TimecapsopSubmitModalContent from '@/components/resolution/submit/TimecapsopSubmitModalContent';
 import { zIndex } from '@/styles/zIndex';
 
-const schema = yup.object().shape({
-  tags: yup
-    .array()
-    .of(yup.boolean())
-    .test('tags', '목표를 선택해 주세요', (value) => value?.some((v) => v === true) ?? false),
-  content: yup.string().required('내용을 입력해 주세요').max(300, '300자 이내로 입력해 주세요'),
-});
-
-interface TimecapsopForm {
-  tags: TimecapsopTag[];
-  content: string;
-}
-
-interface TimecapsopSubmitModalProps extends ModalProps {
-  userName: string;
+interface TimecapsopSubmitModalProps {
+  onClose: () => void;
   onSuccess: () => void;
+  userName: string;
+  isOpen: boolean;
 }
 
-const TimecapsopSubmitModal: FC<TimecapsopSubmitModalProps> = ({ userName, ...props }) => {
-  const { handleConfirmResolution, isPending } = useConfirmResolution();
-  const [selectedTag, setSelectedTag] = useState<TimecapsopTag[]>([]);
-  const { handleSubmit, control, formState } = useForm<TimecapsopForm>({
-    resolver: yupResolver(schema),
-    mode: 'onChange',
-  });
-
-  const isValid = formState.isValid;
-
-  const onClickTag = (tag: TimecapsopTag) => {
-    if (selectedTag.includes(tag)) {
-      setSelectedTag(selectedTag.filter((t) => t !== tag));
-    } else {
-      setSelectedTag([...selectedTag, tag]);
-    }
-  };
-
-  const submit = async ({ content }: TimecapsopForm) => {
-    try {
-      if (!isValid) return;
-      handleConfirmResolution({
-        content,
-        tags: selectedTag,
-        onSuccess: () => {
-          props.onClose();
-          props.onSuccess();
-        },
-      });
-    } catch (error) {
-      throw error;
-    }
+const TimecapsopSubmitModal: FC<TimecapsopSubmitModalProps> = ({ onClose, onSuccess, userName, isOpen }) => {
+  const router = useRouter();
+  const handleClose = () => {
+    onClose();
+    router.push(playgroundLink.feedList());
   };
 
   return (
-    <StyledModal isOpen {...props} zIndex={zIndex.헤더 + 100} onOpenAutoFocus={(e) => e.preventDefault()}>
-      <StyledForm onSubmit={handleSubmit(submit)}>
-        <ModalBody>
-          <TitleTextWrapper>
-            <Description typography='SUIT_14_M' color={colors.gray200}>
-              SOPT 36기를 시작하는 나를 응원하며
-            </Description>
-            <Text typography='SUIT_20_SB'>타임캡솝을 만들어볼까요?</Text>
-          </TitleTextWrapper>
-
-          <TagsWrapper>
-            <TagTextWrapper>
-              <Text typography='SUIT_14_M' color={colors.gray30}>
-                나만의 목표를 담아보세요
-              </Text>
-              <Text typography='SUIT_14_M' color={colors.gray400}>
-                (다중 선택 가능)
-              </Text>
-            </TagTextWrapper>
-            <StyledTags>
-              {TAG.map((tag, index) => (
-                <div key={'wrapper' + index}>
-                  <RHFControllerFormItem
-                    key={'controller' + index}
-                    name={`tags.${index}`}
-                    id={`tags.${index}`}
-                    component={StyledInput}
-                    control={control}
-                    type='checkbox'
-                    style={{ display: 'none' }}
-                  />
-                  <StyledTagItem
-                    key={'tagItem' + index}
-                    htmlFor={`tags.${index}`}
-                    onClick={() => onClickTag(tag.value)}
-                    isSelected={selectedTag.includes(tag.value)}
-                    defaultImg={tag.image.default}
-                    selectedImg={tag.image.select}
-                    hoverImg={tag.image.hover}
-                  >
-                    <StyledTagText
-                      typography='SUIT_14_SB'
-                      color={selectedTag.includes(tag.value) ? colors.white : colors.gray300}
-                    >
-                      {tag.value}
-                    </StyledTagText>
-                  </StyledTagItem>
-                </div>
-              ))}
-            </StyledTags>
-          </TagsWrapper>
-
-          {formState.errors?.tags && (
-            <TagErrorWrapper>
-              <StyledIconAlertCircle color={colors.error} />
-              <TagErrorMessage typography='SUIT_12_SB'>{formState.errors?.tags.message}</TagErrorMessage>
-            </TagErrorWrapper>
-          )}
-          <TextAreaWrapper>
-            <ReceiverText typography='SUIT_16_SB' color={colors.gray50}>
-              {`To. 7월의 ${userName}`}
-            </ReceiverText>
-            <Controller
-              name='content'
-              control={control}
-              render={({ field, fieldState }) => (
-                <StyledTextArea
-                  {...field}
-                  fixedHeight={156}
-                  maxLength={300}
-                  placeholder={
-                    '(예시) 드디어 솝트 36기 시작! 이걸 보고 있다면 36기 종무식을 하고 있겠지?\n세미나 과제랑 스터디 진짜진짜 열심히 해서 많이 배우고, 앱잼 팀원과 좋은 프로덕트 꼭 만들어보자. 팟팅!'
-                  }
-                  errorMessage={fieldState.error?.message}
-                  isError={!!fieldState.error}
-                  value={field.value ?? ''}
-                />
-              )}
-            />
-            <SenderText typography='SUIT_16_SB' color={colors.gray50}>
-              {`From. 3월의 ${userName}`}
-            </SenderText>
-          </TextAreaWrapper>
-        </ModalBody>
-        <StyledButton isDisabled={!isValid} isError={!formState.errors.content}>
-          {isPending ? (
-            <Loading color='white' />
-          ) : (
-            <Text
-              typography={window.innerWidth <= MOBILE_MAX_WIDTH ? 'SUIT_16_SB' : 'SUIT_18_SB'}
-              color={isValid ? colors.black : colors.gray500}
-            >
-              타임캡솝 보관하기
-            </Text>
-          )}
-        </StyledButton>
-      </StyledForm>
-    </StyledModal>
+    <>
+      <Responsive only='desktop'>
+        <StyledModal isOpen={isOpen} onClose={handleClose} zIndex={zIndex.헤더 + 100}>
+          <TimecapsopSubmitModalContent userName={userName} onClose={onClose} onSuccess={onSuccess} />
+        </StyledModal>
+      </Responsive>
+
+      <Responsive only='mobile'>
+        <ModalBottomSheet isOpen={isOpen} onClose={handleClose}>
+          <TimecapsopSubmitModalContent userName={userName} onClose={onClose} onSuccess={onSuccess} />
+        </ModalBottomSheet>
+      </Responsive>
+    </>
   );
 };
 
 export default TimecapsopSubmitModal;
 
 const StyledModal = styled(Modal)`
+  border-radius: 14px;
   background-color: ${colors.gray900};
   max-height: 100vh;
   overflow-y: auto;
@@ -182,175 +53,7 @@ const StyledModal = styled(Modal)`
     display: none;
   }
 
-  @media ${MOBILE_MEDIA_QUERY} {
-    max-width: 100%;
-  }
-
   @supports (height: 100dvh) {
     max-height: 100dvh;
   }
 `;
-
-const Description = styled(Text)`
-  text-align: center;
-  line-height: 22px;
-  white-space: pre-wrap;
-`;
-
-const StyledForm = styled.form`
-  display: flex;
-  flex-direction: column;
-  gap: 28px;
-  align-items: center;
-  padding: 18px;
-  width: 430px;
-  min-width: 320px;
-
-  @media ${MOBILE_MEDIA_QUERY} {
-    @supports (height: 100dvw) {
-      max-width: 100dvw;
-    }
-
-    gap: 24px;
-  }
-`;
-
-const StyledTags = styled.section`
-  display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
-  justify-content: center;
-  width: 294px;
-`;
-
-const StyledTagText = styled(Text)`
-  position: absolute;
-  top: 58px;
-`;
-
-const StyledTagItem = styled.label<{ isSelected: boolean; defaultImg: string; selectedImg: string; hoverImg: string }>`
-  display: flex;
-  position: relative;
-  align-items: center;
-  justify-content: center;
-  transition: all 0.2s;
-  border-radius: 50%;
-  background-image: ${({ isSelected, defaultImg, selectedImg }) => `url(${isSelected ? selectedImg : defaultImg}) `};
-  cursor: pointer;
-  width: 90px;
-  height: 90px;
-
-  &:hover {
-    ${({ isSelected, hoverImg }) => !isSelected && `background-image: url(${hoverImg});`}
-  }
-`;
-
-const StyledTextArea = styled(TextArea)`
-  width: 100%;
-
-  @media ${MOBILE_MEDIA_QUERY} {
-    @supports (height: 100dvw) {
-      max-width: calc(100vw - 40px);
-    }
-  }
-`;
-
-const StyledInput = styled.input``;
-
-const StyledButton = styled.button<{ isDisabled: boolean; isError: boolean }>`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: background-color 0.2s;
-  border-radius: 12px;
-  background: ${({ isDisabled }) => (isDisabled ? colors.gray800 : 'linear-gradient(90deg, #d5d6e3 0%, #939aab 100%)')};
-  cursor: pointer;
-  padding: 12px 20px;
-  width: 100%;
-  height: 56px;
-
-  &:hover {
-    background-color: ${colors.gray50};
-    color: ${colors.black};
-  }
-
-  @media ${MOBILE_MEDIA_QUERY} {
-    height: 44px;
-  }
-`;
-
-const TagTextWrapper = styled.div`
-  display: flex;
-  gap: 3px;
-  align-items: center;
-  height: 22px;
-`;
-
-const TagErrorWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-top: -24px;
-  width: 100%;
-  height: 16px;
-
-  & > svg {
-    margin-right: 6px;
-  }
-`;
-
-const TagErrorMessage = styled(Text)`
-  color: ${colors.error};
-`;
-
-const StyledIconAlertCircle = styled(IconAlertCircle)`
-  width: 14px;
-  height: 14px;
-`;
-
-const TitleTextWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  align-items: center;
-  width: 100%;
-`;
-
-const ModalBody = styled.div`
-  display: flex;
-  flex-direction: column;
-  gap: 24px;
-  align-items: center;
-  margin-top: 56px;
-  width: 100%;
-
-  @media ${MOBILE_MEDIA_QUERY} {
-    gap: 20px;
-    margin-top: 52px;
-  }
-`;
-
-const TagsWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  align-items: center;
-  width: 100%;
-`;
-
-const TextAreaWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  width: 100%;
-`;
-
-const SenderText = styled(Text)`
-  display: flex;
-  justify-content: flex-end;
-  width: 100%;
-  line-height: 24px;
-`;
-
-const ReceiverText = styled(Text)`
-  line-height: 24px;
-`;
diff --git a/src/components/resolution/submit/TimecapsopSubmitModalContent.tsx b/src/components/resolution/submit/TimecapsopSubmitModalContent.tsx
new file mode 100644
index 000000000..6aa541eb1
--- /dev/null
+++ b/src/components/resolution/submit/TimecapsopSubmitModalContent.tsx
@@ -0,0 +1,334 @@
+import styled from '@emotion/styled';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { colors } from '@sopt-makers/colors';
+import { IconAlertCircle } from '@sopt-makers/icons';
+import { TextArea } from '@sopt-makers/ui';
+import { FC, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import * as yup from 'yup';
+
+import RHFControllerFormItem from '@/components/common/form/RHFControllerFormItem';
+import Loading from '@/components/common/Loading';
+import Text from '@/components/common/Text';
+import { ModalProps } from '@/components/members/detail/MessageSection/Modal';
+import { TAG, TimecapsopTag } from '@/components/resolution/constants';
+import { useConfirmResolution } from '@/components/resolution/submit/useConfirmResolution';
+import { MOBILE_MAX_WIDTH, MOBILE_MEDIA_QUERY } from '@/styles/mediaQuery';
+
+const schema = yup.object().shape({
+  tags: yup
+    .array()
+    .of(yup.boolean())
+    .test('tags', '목표를 선택해 주세요', (value) => value?.some((v) => v === true) ?? false),
+  content: yup.string().required('내용을 입력해 주세요').max(300, '300자 이내로 입력해 주세요'),
+});
+
+interface TimecapsopForm {
+  tags: (keyof typeof TimecapsopTag)[];
+  content: string;
+}
+
+interface TimecapsopSubmitModalProps extends ModalProps {
+  userName: string;
+  onSuccess: () => void;
+}
+
+const TimecapsopSubmitModalContent: FC<TimecapsopSubmitModalProps> = ({ userName, ...props }) => {
+  const { handleConfirmResolution, isPending } = useConfirmResolution();
+  const [selectedTag, setSelectedTag] = useState<(keyof typeof TimecapsopTag)[]>([]);
+  const { handleSubmit, control, formState } = useForm<TimecapsopForm>({
+    resolver: yupResolver(schema),
+    mode: 'onChange',
+  });
+
+  const isValid = formState.isValid;
+
+  const onClickTag = (tag: keyof typeof TimecapsopTag) => {
+    if (selectedTag.includes(tag)) {
+      setSelectedTag(selectedTag.filter((t) => t !== tag));
+    } else {
+      setSelectedTag([...selectedTag, tag]);
+    }
+  };
+
+  const submit = async ({ content }: TimecapsopForm) => {
+    try {
+      if (!isValid) return;
+      handleConfirmResolution({
+        content,
+        tags: selectedTag,
+        onSuccess: () => {
+          props.onClose();
+          props.onSuccess();
+        },
+      });
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  return (
+    <StyledForm onSubmit={handleSubmit(submit)}>
+      <ModalBody>
+        <TitleTextWrapper>
+          <Description typography='SUIT_14_M' color={colors.gray200}>
+            SOPT 36기를 시작하는 나를 응원하며
+          </Description>
+          <Text typography='SUIT_20_SB'>타임캡솝을 만들어볼까요?</Text>
+        </TitleTextWrapper>
+
+        <TagsWrapper>
+          <TagTextWrapper>
+            <Text typography='SUIT_14_M' color={colors.gray30}>
+              나만의 목표를 담아보세요
+            </Text>
+            <Text typography='SUIT_14_M' color={colors.gray400}>
+              (다중 선택 가능)
+            </Text>
+          </TagTextWrapper>
+          <StyledTags>
+            {TAG.map((tag, index) => (
+              <div key={'wrapper' + index}>
+                <RHFControllerFormItem
+                  key={'controller' + index}
+                  name={`tags.${index}`}
+                  id={`tags.${index}`}
+                  component={StyledInput}
+                  control={control}
+                  type='checkbox'
+                  style={{ display: 'none' }}
+                />
+                <StyledTagItem
+                  key={'tagItem' + index}
+                  htmlFor={`tags.${index}`}
+                  onClick={() => onClickTag(tag.key)}
+                  isSelected={selectedTag.includes(tag.key)}
+                  defaultImg={tag.image.default}
+                  selectedImg={tag.image.select}
+                  hoverImg={tag.image.hover}
+                >
+                  <StyledTagText
+                    typography='SUIT_14_SB'
+                    color={selectedTag.includes(tag.key) ? colors.white : colors.gray300}
+                  >
+                    {tag.value}
+                  </StyledTagText>
+                </StyledTagItem>
+              </div>
+            ))}
+          </StyledTags>
+        </TagsWrapper>
+
+        {formState.errors?.tags && (
+          <TagErrorWrapper>
+            <StyledIconAlertCircle color={colors.error} />
+            <TagErrorMessage typography='SUIT_12_SB'>{formState.errors?.tags.message}</TagErrorMessage>
+          </TagErrorWrapper>
+        )}
+        <TextAreaWrapper>
+          <ReceiverText typography='SUIT_16_SB' color={colors.gray50}>
+            {`To. 7월의 ${userName}`}
+          </ReceiverText>
+          <Controller
+            name='content'
+            control={control}
+            render={({ field, fieldState }) => (
+              <StyledTextArea
+                {...field}
+                fixedHeight={156}
+                maxLength={300}
+                placeholder={
+                  '(예시) 드디어 솝트 36기 시작! 이걸 보고 있다면 36기 종무식을 하고 있겠지?\n세미나 과제랑 스터디 진짜진짜 열심히 해서 많이 배우고, 앱잼 팀원과 좋은 프로덕트 꼭 만들어보자. 팟팅!'
+                }
+                errorMessage={fieldState.error?.message}
+                isError={!!fieldState.error}
+                value={field.value ?? ''}
+              />
+            )}
+          />
+          <SenderText typography='SUIT_16_SB' color={colors.gray50}>
+            {`From. 3월의 ${userName}`}
+          </SenderText>
+        </TextAreaWrapper>
+      </ModalBody>
+      <StyledButton isDisabled={!isValid} isError={!formState.errors.content}>
+        {isPending ? (
+          <Loading color='white' />
+        ) : (
+          <Text
+            typography={window.innerWidth <= MOBILE_MAX_WIDTH ? 'SUIT_16_SB' : 'SUIT_18_SB'}
+            color={isValid ? colors.black : colors.gray500}
+          >
+            타임캡솝 보관하기
+          </Text>
+        )}
+      </StyledButton>
+    </StyledForm>
+  );
+};
+
+export default TimecapsopSubmitModalContent;
+
+const Description = styled(Text)`
+  text-align: center;
+  line-height: 22px;
+  white-space: pre-wrap;
+`;
+
+const StyledForm = styled.form`
+  display: flex;
+  flex-direction: column;
+  gap: 28px;
+  align-items: center;
+  padding: 18px;
+  width: 430px;
+  min-width: 320px;
+
+  @media ${MOBILE_MEDIA_QUERY} {
+    @supports (height: 100dvw) {
+      max-width: 100dvw;
+    }
+
+    gap: 24px;
+  }
+`;
+
+const StyledTags = styled.section`
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  justify-content: center;
+  width: 294px;
+`;
+
+const StyledTagText = styled(Text)`
+  position: absolute;
+  top: 58px;
+`;
+
+const StyledTagItem = styled.label<{ isSelected: boolean; defaultImg: string; selectedImg: string; hoverImg: string }>`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.2s;
+  border-radius: 50%;
+  background-image: ${({ isSelected, defaultImg, selectedImg }) => `url(${isSelected ? selectedImg : defaultImg}) `};
+  cursor: pointer;
+  width: 90px;
+  height: 90px;
+
+  &:hover {
+    ${({ isSelected, hoverImg }) => !isSelected && `background-image: url(${hoverImg});`}
+  }
+`;
+
+const StyledTextArea = styled(TextArea)`
+  width: 100%;
+
+  @media ${MOBILE_MEDIA_QUERY} {
+    @supports (height: 100dvw) {
+      max-width: calc(100vw - 40px);
+    }
+  }
+`;
+
+const StyledInput = styled.input``;
+
+const StyledButton = styled.button<{ isDisabled: boolean; isError: boolean }>`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background-color 0.2s;
+  border-radius: 12px;
+  background: ${({ isDisabled }) => (isDisabled ? colors.gray800 : 'linear-gradient(90deg, #d5d6e3 0%, #939aab 100%)')};
+  cursor: pointer;
+  padding: 12px 20px;
+  width: 100%;
+  height: 56px;
+
+  &:hover {
+    background-color: ${colors.gray50};
+    color: ${colors.black};
+  }
+
+  @media ${MOBILE_MEDIA_QUERY} {
+    height: 44px;
+  }
+`;
+
+const TagTextWrapper = styled.div`
+  display: flex;
+  gap: 3px;
+  align-items: center;
+  height: 22px;
+`;
+
+const TagErrorWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -24px;
+  width: 100%;
+  height: 16px;
+
+  & > svg {
+    margin-right: 6px;
+  }
+`;
+
+const TagErrorMessage = styled(Text)`
+  color: ${colors.error};
+`;
+
+const StyledIconAlertCircle = styled(IconAlertCircle)`
+  width: 14px;
+  height: 14px;
+`;
+
+const TitleTextWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  align-items: center;
+  width: 100%;
+`;
+
+const ModalBody = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+  align-items: center;
+  margin-top: 56px;
+  width: 100%;
+
+  @media ${MOBILE_MEDIA_QUERY} {
+    gap: 20px;
+    margin-top: 52px;
+  }
+`;
+
+const TagsWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  align-items: center;
+  width: 100%;
+`;
+
+const TextAreaWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  width: 100%;
+`;
+
+const SenderText = styled(Text)`
+  display: flex;
+  justify-content: flex-end;
+  width: 100%;
+  line-height: 24px;
+`;
+
+const ReceiverText = styled(Text)`
+  line-height: 24px;
+`;
diff --git a/src/components/resolution/submit/useConfirmResolution.ts b/src/components/resolution/submit/useConfirmResolution.ts
index 3ef488818..3a6e22389 100644
--- a/src/components/resolution/submit/useConfirmResolution.ts
+++ b/src/components/resolution/submit/useConfirmResolution.ts
@@ -1,50 +1,26 @@
-import { colors } from '@sopt-makers/colors';
-import { useToast } from '@sopt-makers/ui';
-import { useRouter } from 'next/router';
-import { playgroundLink } from 'playground-common/export';
 import { useCallback } from 'react';
 
 import { ResolutionRequestBody, usePostResolutionMutation } from '@/api/endpoint/resolution/postResolution';
-import useConfirm from '@/components/common/Modal/useConfirm';
 import useEventLogger from '@/components/eventLogger/hooks/useEventLogger';
-import { zIndex } from '@/styles/zIndex';
 
 interface Options extends ResolutionRequestBody {
   onSuccess?: () => void;
 }
 
 export const useConfirmResolution = () => {
-  const { confirm } = useConfirm();
   const { mutateAsync, isPending } = usePostResolutionMutation();
-  const { open: toastOpen } = useToast();
   const { logSubmitEvent } = useEventLogger();
-  const router = useRouter();
 
   const handleConfirmResolution = useCallback(
     async (options: Options) => {
-      const result = await confirm({
-        title: '다짐을 보내시겠습니까?',
-        description: '한번 보낸 다짐은 수정할 수 없고, 종무식 때 다시 열어볼 수 있어요. 신중히 다짐해 주세요!',
-        okButtonText: '보내기',
-        cancelButtonText: '취소',
-        maxWidth: 400,
-        zIndex: zIndex.헤더 + 101,
-        okButtonColor: 'linear-gradient(90deg, #8FC0FF 0%, #5BA3FF 100%)',
-        okButtonTextColor: colors.black,
+      mutateAsync(options, {
+        onSuccess: async () => {
+          logSubmitEvent('postResolution');
+          options.onSuccess?.();
+        },
       });
-
-      if (result) {
-        mutateAsync(options, {
-          onSuccess: async () => {
-            logSubmitEvent('postResolution');
-            toastOpen({ content: '💌 다짐을 들려주셔서 감사해요.' });
-            options.onSuccess?.();
-            await router.push(playgroundLink.feedList());
-          },
-        });
-      }
     },
-    [confirm, mutateAsync, toastOpen, logSubmitEvent, router],
+    [mutateAsync, logSubmitEvent],
   );
 
   return { handleConfirmResolution, isPending };
diff --git a/src/pages/members/complete.tsx b/src/pages/members/complete.tsx
index 0eb0dfe01..a87a15487 100644
--- a/src/pages/members/complete.tsx
+++ b/src/pages/members/complete.tsx
@@ -123,18 +123,17 @@ const CompletePage: FC = () => {
           </Responsive>
         </StyledCompletePage>
       )}
-      {isOpenResolutionModal && (
-        <TimecapsopSubmitModal
-          userName={name ?? '나'}
-          onClose={onCloseResolutionModal}
-          onSuccess={onOpenPlaygroundGuideModal}
-        />
-      )}
+      <TimecapsopSubmitModal
+        userName={name ?? '나'}
+        onClose={onCloseResolutionModal}
+        onSuccess={onOpenPlaygroundGuideModal}
+        isOpen={isOpenResolutionModal}
+      />
       {isOpenPlaygroundGuideModal && (
         <PlaygroundGuideModal
           onClose={() => {
             onClosePlaygroundGuideModal();
-            router.push(playgroundLink.intro());
+            router.push(playgroundLink.feedList());
           }}
         />
       )}


Reviews:
user: login: imddoy
body: 확인했습니다!! 빠르게 작업해주셔서 감사합니다! :)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:40:23Z
user: login: seong-hui
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T17:46:01Z

Review comments:
diff_hunk: @@ -51,18 +54,29 @@ const PlaygroundGuideModal = ({ isAlreadyRegistration, ...props }: PlaygroundGui
             icon={card.icon}
             button={card.button}
             href={card.href}
+            onClose={props.onClose}
           />
         ))}
       </CardWrapper>
+      {DEBUG && <TimecapsopDelteButton />}
     </StyledModal>
   );
 };
 
 export default PlaygroundGuideModal;
 
-const Card = ({ name, description, color, hover, icon, button, href }: CardProps) => {
+const Card = ({ name, description, color, hover, icon, button, href, onClose }: CardProps) => {
+  const router = useRouter();
+
+  const handleClick = async (e: React.MouseEvent) => {
+    e.preventDefault();
+    await router.replace('/', undefined, { shallow: true });
+    router.push(href);
+    onClose();
+  };
path: src/components/resolution/submit/PlaygroundGuideModal.tsx
user: login: imddoy
body: onClose()의 역할은 무엇인가요??
created_at: 2025-03-26T17:37:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -123,18 +123,17 @@ const CompletePage: FC = () => {
           </Responsive>
         </StyledCompletePage>
       )}
-      {isOpenResolutionModal && (
-        <TimecapsopSubmitModal
-          userName={name ?? '나'}
-          onClose={onCloseResolutionModal}
-          onSuccess={onOpenPlaygroundGuideModal}
-        />
-      )}
+      <TimecapsopSubmitModal
+        userName={name ?? '나'}
+        onClose={onCloseResolutionModal}
+        onSuccess={onOpenPlaygroundGuideModal}
+        isOpen={isOpenResolutionModal}
+      />
       {isOpenPlaygroundGuideModal && (
         <PlaygroundGuideModal
           onClose={() => {
             onClosePlaygroundGuideModal();
-            router.push(playgroundLink.intro());
+            router.push(playgroundLink.feedList());
           }}
path: src/pages/members/complete.tsx
user: login: imddoy
body: 감사합니다 ㅎㅎㅎ
created_at: 2025-03-26T17:39:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,18 +54,29 @@ const PlaygroundGuideModal = ({ isAlreadyRegistration, ...props }: PlaygroundGui
             icon={card.icon}
             button={card.button}
             href={card.href}
+            onClose={props.onClose}
           />
         ))}
       </CardWrapper>
+      {DEBUG && <TimecapsopDelteButton />}
     </StyledModal>
   );
 };
 
 export default PlaygroundGuideModal;
 
-const Card = ({ name, description, color, hover, icon, button, href }: CardProps) => {
+const Card = ({ name, description, color, hover, icon, button, href, onClose }: CardProps) => {
+  const router = useRouter();
+
+  const handleClick = async (e: React.MouseEvent) => {
+    e.preventDefault();
+    await router.replace('/', undefined, { shallow: true });
+    router.push(href);
+    onClose();
+  };
path: src/components/resolution/submit/PlaygroundGuideModal.tsx
user: login: seong-hui
body: 기존 모달을 닫는 역할입니다! 이전의 코드에서 모달을 닫지 않고 경로이동 후 다시 이전으로 돌아갔을 때 모달이 켜져있는 상황이 발생하여 onClose를 추가하였는데 useRouter를 이용해서 이동을 하는 방식으로 수정해서 close를 하지 않아도 모달이 닫힐것 같기는 하네요..!
created_at: 2025-03-26T17:46:00Z
author_association: MEMBER

PR comments:
user: login: height[bot]
created_at: 2025-03-24T18:56:31Z
body: > Link [Height](https://height.app/QvNPTzDUr8) tasks by mentioning a task ID in the pull request title or commit messages, or description and comments with the keyword `link` (e.g. "Link T-123").
>
> 💡Tip: You can also use "Close T-X" to automatically close a task when the pull request is merged.
<!-- Sent by Height -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-24T18:58:38Z
body: ✨✨ 스토리북으로 확인하기 ✨✨

- [Build](https://www.chromatic.com/build?appId=62c412f0bcadc13b841ca857&number=3256)
- [Preview](https://62c412f0bcadc13b841ca857-khpxpcuroi.chromatic.com/)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-24T18:59:06Z
body: 🚀 프리뷰 배포 확인하기 🚀

https://82fdd772.sopt-internal-dev.pages.dev
author_association: NONE