diff --git a/.github/workflows/Auto_PR_Setting.yml b/.github/workflows/Auto_PR_Setting.yml deleted file mode 100644 index f2382397..00000000 --- a/.github/workflows/Auto_PR_Setting.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Auto PR Setting - -on: - pull_request: - types: [opened] - -jobs: - assign-issue: - # Works only if target branch name is not 'main' - if: github.base_ref != 'main' - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Set Auth Token - run: echo "GH_TOKEN=${{ secrets.PERSONAL_ACCESS_TOKEN }}" >> $GITHUB_ENV - # Personal Access Token을 환경 변수에 설정하여 gh CLI에서 인증에 사용. - - - name: Get Issue Number - id: get_issue - run: | - # PR 정보를 가져와 브랜치 이름 확인. - PR_DATA=$(gh pr view ${{ github.event.pull_request.number }} --json headRefName) - echo "PR_DATA=${PR_DATA}" >> $GITHUB_ENV - # 브랜치 이름에서 이슈 번호 추출. - BRANCH_NAME=$(echo "${PR_DATA}" | jq -r '.headRefName') - echo "BRANCH_NAME=${BRANCH_NAME}" >> $GITHUB_ENV - ISSUE_NUMBER=$(echo "${BRANCH_NAME}" | awk -F'#' '{print $2}' | grep -Eo '^[0-9]+') - echo "ISSUE_NUMBER=${ISSUE_NUMBER}" >> $GITHUB_ENV - - - name: Get Issue Details - id: get_issue_details - run: | - # 이슈 정보를 가져와 할당된 사람, 레이블, 프로젝트 항목 정보를 추출. - ISSUE_DATA=$(gh issue view "${ISSUE_NUMBER}" --json assignees,labels,projectItems --jq '{assignees: [.assignees[].login], labels: [.labels[].name], projects: [.projectItems[].title]}') - echo "ISSUE_DATA=${ISSUE_DATA}" >> $GITHUB_ENV - - - name: Setting PR - id: setting_pr - run: | - # 이슈 데이터에서 할당된 사람, 레이블, 프로젝트 정보를 환경 변수로 설정. - ASSIGNEES=$(echo "${ISSUE_DATA}" | jq -r '.assignees | join(",")') - LABELS=$(echo "${ISSUE_DATA}" | jq -r '.labels | join(",")') - - # 팀 멤버 목록을 정의하고, 할당되지 않은 멤버를 리뷰어로 추가. - # 단, 현재 활동하지 않는 멤버는 제외. (현재 비활성 멤버 = 엘, 케이티) - TEAM_MEMBERS=("jaeml06" "i-meant-to-be" "useon") - IFS=', ' read -r -a ASSIGNEE_ARRAY <<< "${ASSIGNEES}" - REVIEWERS=() - for MEMBER in "${TEAM_MEMBERS[@]}"; do - if [[ ! " ${ASSIGNEE_ARRAY[@]} " =~ " ${MEMBER} " ]]; then - REVIEWERS+=("${MEMBER}") - fi - done - REVIEWER_LIST=$(IFS=', '; echo "${REVIEWERS[*]}") - - # PR에 할당된 사람, 레이블, 리뷰어, 프로젝트를 추가. - gh pr edit ${{ github.event.pull_request.number }} --add-assignee "${ASSIGNEES}" --add-label "${LABELS}" --add-reviewer "${REVIEWER_LIST}" diff --git a/src/assets/patchNote/0001.png b/src/assets/patchNote/0001.png new file mode 100644 index 00000000..aa007b83 Binary files /dev/null and b/src/assets/patchNote/0001.png differ diff --git a/src/components/UpdateModal/MegaphoneAsset.tsx b/src/components/UpdateModal/MegaphoneAsset.tsx new file mode 100644 index 00000000..6d2d6cf2 --- /dev/null +++ b/src/components/UpdateModal/MegaphoneAsset.tsx @@ -0,0 +1,376 @@ +interface MegaphoneProps { + className?: string; +} + +export default function MegaphoneAsset({ className = '' }: MegaphoneProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/UpdateModal/NoticeAsset.tsx b/src/components/UpdateModal/NoticeAsset.tsx new file mode 100644 index 00000000..f9ff24a3 --- /dev/null +++ b/src/components/UpdateModal/NoticeAsset.tsx @@ -0,0 +1,21 @@ +interface NoticeAssetProps { + className?: string; +} + +export default function NoticeAsset({ className = '' }: NoticeAssetProps) { + return ( + + + + ); +} diff --git a/src/components/UpdateModal/UpdateModal.stories.tsx b/src/components/UpdateModal/UpdateModal.stories.tsx new file mode 100644 index 00000000..5fa857b5 --- /dev/null +++ b/src/components/UpdateModal/UpdateModal.stories.tsx @@ -0,0 +1,30 @@ +import { Meta, StoryObj } from '@storybook/react'; +import UpdateModal from './UpdateModal'; // 이미지 임포트는 아래와 같이 +import PatchNoteImage from '../../assets/patchNote/0001.png'; +import { PredefinedPatchNoteData } from '../../constants/patch_note'; + +const meta: Meta = { + title: 'components/UpdateModal', + component: UpdateModal, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: { + version: '0000', + title: '피드백 & 투표', + description: + '토론 종료 후 피드백 & 투표 기능으로\n다양한 서비스를 이용하세요!', + link: 'https://notion.so/', + image: PatchNoteImage, + mode: 'predefined', + } as PredefinedPatchNoteData, + isChecked: false, + onChecked: () => {}, + }, +}; diff --git a/src/components/UpdateModal/UpdateModal.tsx b/src/components/UpdateModal/UpdateModal.tsx new file mode 100644 index 00000000..ec50d9d0 --- /dev/null +++ b/src/components/UpdateModal/UpdateModal.tsx @@ -0,0 +1,124 @@ +import MegaphoneAsset from './MegaphoneAsset'; +import NoticeAsset from './NoticeAsset'; +import { + ImageOnlyPatchNoteData, + isPredefinedPatchNote, + PatchNoteData, + PredefinedPatchNoteData, +} from '../../constants/patch_note'; + +interface UpdateModalProps { + data: PatchNoteData; + isChecked: boolean; + onChecked: (value: boolean) => void; + onClickDetailButton: () => void; +} + +export default function UpdateModal({ + data, + isChecked, + onChecked, + onClickDetailButton, +}: UpdateModalProps) { + return ( +
+ {isPredefinedPatchNote(data) ? ( + <> + {/* 메인 컨텐츠 */} +
+
+
+ +
+ +
+

+ 디베이트 타이머에 새로운 기능이 생겼어요! +

+ +
+ +
+
+
+ +
+ {data.image && ( + 업데이트 이미지 + )} +
+
+ + {/* 텍스트 컨텐츠 */} +
+ {/* 타이틀 및 내용 */} +
+

+ {(data as PredefinedPatchNoteData).title} +

+
+

+ {(data as PredefinedPatchNoteData).description} +

+
+ + {/* '일주일 간 보지 않기' 체크박스 */} + +
+ + ) : ( +
+ {/* 이미지 컨텐츠 */} + {(data as ImageOnlyPatchNoteData).image && ( + 업데이트 이미지 + )} + + {/* '일주일 간 보지 않기' 체크박스 */} + +
+ )} + + {/* 버튼 영역 */} + +
+ ); +} diff --git a/src/components/UpdateModal/UpdateModalWrapper.tsx b/src/components/UpdateModal/UpdateModalWrapper.tsx new file mode 100644 index 00000000..62bfc9fe --- /dev/null +++ b/src/components/UpdateModal/UpdateModalWrapper.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import { useModal } from '../../hooks/useModal'; +import UpdateModal from './UpdateModal'; +import { LATEST_PATCH_NOTE } from '../../constants/patch_note'; + +const STORAGE_KEY = 'update_notification_status'; +const DISMISS_DURATION_DAYS = 7; +const MILLIS_IN_A_DAY = 1000 * 3600 * 24; + +// 로컬 스토리지에 저장될 패치 노트의 데이터 타입 정의 +interface StoredStatus { + version: string; // 패치 노트 버전 + dismissedAt: string; // 일주일 간 무시하기 체크 시 기록되는 시간 +} + +export default function UpdateModalWrapper() { + // 상태 관리, 환경 변수 및 기타 변수 선언 + const [isChecked, setIsChecked] = useState(false); + const isCheckedRef = useRef(isChecked); + + // 모달 훅 사용 + const { openModal, closeModal, ModalWrapper } = useModal({ + onClose: () => { + // 모달 닫을 때 '일주일 간 보지 않기'가 체크되어 있으면, 현재 시간과 패치 노트 버전을 로컬 저장소에 기록 + if (isCheckedRef.current) { + const status: StoredStatus = { + version: LATEST_PATCH_NOTE.version, + dismissedAt: new Date().toISOString(), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(status)); + } + }, + }); + + const handleCheckedChange = (checked: boolean) => { + setIsChecked(checked); + isCheckedRef.current = checked; + }; + + const handleClickDetailButton = () => { + const link = LATEST_PATCH_NOTE.link; + + if (link) { + window.open(link, '_blank', 'noopener,noreferrer'); + } else { + alert('패치 노트 링크를 읽는 중 오류가 발생했습니다.'); + } + + closeModal(); + }; + + // 모달이 열리는 조건 확인 + useEffect(() => { + // 로컬 저장소에 저장된 날짜가 있다면, 검증을 거쳐 안전히 불러오기 + const rawData = localStorage.getItem(STORAGE_KEY); + + // 데이터가 없는 경우(= 처음 접속한 경우), 모달 표시 + if (!rawData) { + openModal(); + return; + } + + try { + // 값 불러오기 + const status: StoredStatus = JSON.parse(rawData); + const dismissDate = new Date(status.dismissedAt); + + // 값 검증 실패 시, 모달 표시 + if (!status.version || isNaN(dismissDate.getTime())) { + openModal(); + return; + } + + // 패치 노트 버전이 다를 시, 모달 표시 + if (status.version !== LATEST_PATCH_NOTE.version) { + openModal(); + return; + } + + // 버전이 동일하다면, 7일이 지났는지 확인 + const now = new Date(); + const timeDiff = now.getTime() - dismissDate.getTime(); + const daysDiff = Math.floor(timeDiff / MILLIS_IN_A_DAY); + + if (daysDiff >= DISMISS_DURATION_DAYS) { + openModal(); + return; + } + } catch { + // 기타 오류나 예외 발생 시, 모달 표시 + openModal(); + return; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 페이지 열릴 때 최초 1회만 실행되도록 의존성 배열을 비웠음 + + return ( + <> + + + + + ); +} diff --git a/src/constants/patch_note.ts b/src/constants/patch_note.ts new file mode 100644 index 00000000..e9339237 --- /dev/null +++ b/src/constants/patch_note.ts @@ -0,0 +1,53 @@ +// 이미지 임포트는 아래와 같이 +import PatchNoteImage from '../assets/patchNote/0001.png'; + +// 기본적인 패치 노트 인터페이스 +interface BasePatchNoteData { + version: string; // 로컬 스토리지 키 관리를 위한 버전 (이 버전을 바꾸면 사용자의 '다시 보지 않기'가 초기화됨) + link: string; + image: string; +} + +// 사전 정의된 패치 노트 인터페이스 +export interface PredefinedPatchNoteData extends BasePatchNoteData { + mode: 'predefined'; + title: string; + description: string; +} + +// 이미지만 존재하는 패치 노트 인터페이스 +export interface ImageOnlyPatchNoteData extends BasePatchNoteData { + mode: 'image-only'; +} + +// 패치 노트 데이터 타입 (두 가지 인터페이스의 유니언 타입) +export type PatchNoteData = PredefinedPatchNoteData | ImageOnlyPatchNoteData; + +// PatchNoteData 타입이 PredefinedPatchNoteData인지 ImagePatchNoteData인지 구별하는 함수 +export function isPredefinedPatchNote( + data: PatchNoteData, +): data is PredefinedPatchNoteData { + // 'mode'가 'predefined'인지 확인 + return data.mode === 'predefined'; +} + +// 현재 활성화된 업데이트 데이터 (이 부분만 수정해서 배포하면 됨) +export const LATEST_PATCH_NOTE: PredefinedPatchNoteData = { + mode: 'predefined', + version: '0001', + title: '피드백 & 투표', + description: + '토론 종료 후 피드백 & 투표 기능으로 다양한 서비스를 이용하세요!', + image: PatchNoteImage, + link: 'https://bustling-bathtub-b3a.notion.site/2f41550c60cf80f69227e3145f6e19cc?pvs=143', +}; + +// ImageOnlyPatchNoteData의 예시 +/* +export const TEST_PATCH_NOTE: ImageOnlyPatchNoteData = { + mode: 'image-only', + version: '0001', + image: PatchNoteImage, + link: 'https://bustling-bathtub-b3a.notion.site/2f41550c60cf80f69227e3145f6e19cc?pvs=143', +}; +*/ diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index e19ae4ee..c8e6d17a 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -18,6 +18,7 @@ import VoteParticipationPage from '../page/VoteParticipationPage/VoteParticipati import VoteCompletePage from '../page/VoteCompletePage/VoteCompletePage'; import DebateVoteResultPage from '../page/DebateVoteResultPage/DebateVoteResultPage'; import LanguageWrapper from './LanguageWrapper'; +import UpdateModalWrapper from '../components/UpdateModal/UpdateModalWrapper'; const appRoutes = [ { @@ -108,6 +109,7 @@ const router = createBrowserRouter([ <> + ), children: [