From 382dc3f01ff75bde4c5bed5ffdb0b088b394661a Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Fri, 10 Oct 2025 02:35:53 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[FEAT]=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=EC=B2=B4=20=ED=99=94=EB=A9=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#380)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 전체 화면 로직을 useTimerPageState 훅에 추가 * design: 헤더 아이콘 벡터 써니가 두께 줄인 것으로 변경 * feat: 도움말에 전체 화면 관련 내용 추가 * design: 아이콘에 Figma 시안대로 패딩 추가 * fix: 텍스트가 2줄 이상 늘어나는 문제 수정 * fix: 누락된 의존성 배열 추가 * fix: 아이콘 크기 문제 수정 * fix: 아이콘 벡터에서 빠진 색상 매개변수 추가 * refactor: 전체화면 토글 함수 useCallback 적용하여 개선 * fix: 전체화면 아이콘 너비 오류 수정 * refactor: 전체화면 인터페이스 별도 파일로 분리 * refactor: 전체 화면 로직 별도 훅으로 분리 * feat: 홈, 로그아웃 버튼 클릭 시 전체 화면 끄도록 구현 * feat: 토론 종료 시 전체 화면 끄도록 구현 --- src/components/icons/Help.tsx | 12 +- src/components/icons/Home.tsx | 8 +- src/components/icons/Login.tsx | 20 ++-- src/hooks/useFullscreen.ts | 105 ++++++++++++++++++ .../header/StickyTriSectionHeader.tsx | 18 ++- src/page/TimerPage/TimerPage.tsx | 39 ++++++- .../TimerPage/components/FirstUseToolTip.tsx | 21 ++++ src/page/TimerPage/hooks/useTimerPageState.ts | 9 ++ src/type/fullscreen.d.ts | 17 +++ 9 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useFullscreen.ts create mode 100644 src/type/fullscreen.d.ts diff --git a/src/components/icons/Help.tsx b/src/components/icons/Help.tsx index b90e133e..2be059da 100644 --- a/src/components/icons/Help.tsx +++ b/src/components/icons/Help.tsx @@ -7,24 +7,26 @@ export default function DTHelp({ }: IconProps) { return ( ); diff --git a/src/components/icons/Home.tsx b/src/components/icons/Home.tsx index 746206a7..0ecfd16b 100644 --- a/src/components/icons/Home.tsx +++ b/src/components/icons/Home.tsx @@ -7,19 +7,19 @@ export default function DTHome({ }: IconProps) { return ( diff --git a/src/components/icons/Login.tsx b/src/components/icons/Login.tsx index a0736687..73444881 100644 --- a/src/components/icons/Login.tsx +++ b/src/components/icons/Login.tsx @@ -7,40 +7,40 @@ export default function DTLogin({ }: IconProps) { return ( diff --git a/src/hooks/useFullscreen.ts b/src/hooks/useFullscreen.ts new file mode 100644 index 00000000..958ed1c6 --- /dev/null +++ b/src/hooks/useFullscreen.ts @@ -0,0 +1,105 @@ +import { useCallback, useLayoutEffect, useState } from 'react'; +import { + DocumentWithFullscreen, + HTMLElementWithFullscreen, +} from '../type/fullscreen'; + +// 헬퍼 함수: 현재 전체 화면 요소가 무엇인지 반환 (없으면 null) +const getFullscreenElement = (): Element | null => { + const doc = document as DocumentWithFullscreen; + return ( + doc.fullscreenElement || + doc.webkitFullscreenElement || + doc.mozFullScreenElement || + doc.msFullscreenElement || + null + ); +}; + +// 헬퍼 함수: 전체 화면 진입 +const enterFullscreen = async (element: HTMLElementWithFullscreen) => { + try { + if (element.requestFullscreen) { + await element.requestFullscreen(); + } else if (element.webkitRequestFullscreen) { + await element.webkitRequestFullscreen(); // Safari + } else if (element.mozRequestFullScreen) { + await element.mozRequestFullScreen(); // Firefox + } else if (element.msRequestFullscreen) { + await element.msRequestFullscreen(); // IE11 + } + } catch (error) { + console.error('# Failed to enter fullscreen mode:', error); + } +}; + +// 헬퍼 함수: 전체 화면 해제 +const exitFullscreen = async () => { + const doc = document as DocumentWithFullscreen; + try { + if (doc.exitFullscreen) { + await doc.exitFullscreen(); + } else if (doc.webkitExitFullscreen) { + await doc.webkitExitFullscreen(); // Safari + } else if (doc.mozCancelFullScreen) { + await doc.mozCancelFullScreen(); // Firefox + } else if (doc.msExitFullscreen) { + await doc.msExitFullscreen(); // IE11 + } + } catch (error) { + console.error('# Failed to exit fullscreen mode:', error); + } +}; + +export default function useFullscreen() { + // 전체 화면 여부를 묘사하는 변수 + const [isFullscreen, setIsFullscreen] = useState(!!getFullscreenElement()); + const handleFullscreenChange = useCallback(() => { + setIsFullscreen(!!getFullscreenElement()); + }, []); + + // 이벤트 리스너 등록 + useLayoutEffect(() => { + const EVENTS = [ + 'fullscreenchange', + 'webkitfullscreenchange', + 'mozfullscreenchange', + 'MSFullscreenChange', + ]; + + EVENTS.forEach((event) => { + document.addEventListener(event, handleFullscreenChange); + }); + + return () => { + EVENTS.forEach((event) => { + document.removeEventListener(event, handleFullscreenChange); + }); + }; + }, [handleFullscreenChange]); + + // 토글 함수 + const toggleFullscreen = useCallback(async () => { + const element = document.documentElement as HTMLElementWithFullscreen; + + if (isFullscreen) { + await exitFullscreen(); + } else { + await enterFullscreen(element); + } + }, [isFullscreen]); + + // 값을 직접 입력하는 함수 + const setFullscreen = useCallback(async (value: boolean) => { + const element = document.documentElement as HTMLElementWithFullscreen; + const isCurrentlyFullscreen = !!getFullscreenElement(); + + if (value && !isCurrentlyFullscreen) { + await enterFullscreen(element); + } else if (!value && isCurrentlyFullscreen) { + await exitFullscreen(); + } + }, []); + + return { isFullscreen, toggleFullscreen, setFullscreen }; +} diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index c243f946..f3041208 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -11,6 +11,7 @@ import { useModal } from '../../../hooks/useModal'; import DialogModal from '../../../components/DialogModal/DialogModal'; import DTHome from '../../../components/icons/Home'; import DTLogin from '../../../components/icons/Login'; +import useFullscreen from '../../../hooks/useFullscreen'; // The type of header icons will be declared here. type HeaderIcons = 'home' | 'auth'; @@ -50,6 +51,7 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { const navigate = useNavigate(); const { mutate: logoutMutate } = useLogout(() => navigate('/home')); const { openModal, closeModal, ModalWrapper } = useModal({}); + const { isFullscreen, setFullscreen } = useFullscreen(); const defaultIcons: HeaderIcons[] = ['home', 'auth']; const handleLoginStart = (keepData: boolean) => { @@ -64,7 +66,7 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { {isGuestFlow() && ( <> {/* Guest mode indicator */} -
+
비회원 모드
@@ -82,9 +84,14 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { case 'home': return ( + @@ -98,7 +122,14 @@ export default function TimerPage() { table={data.table} index={index} goToOtherItem={goToOtherItem} - openDoneModal={openLoginAndStoreModalOrGoToDebateEndPage} + openDoneModal={() => { + // 전체 화면 상태에서 토론을 끝낼 경우, 전체 화면을 비활성화 + if (isFullscreen) { + setFullscreen(false); + } + + openLoginAndStoreModalOrGoToDebateEndPage(); + }} className="absolute bottom-[66px] left-1/2 -translate-x-1/2" /> )} diff --git a/src/page/TimerPage/components/FirstUseToolTip.tsx b/src/page/TimerPage/components/FirstUseToolTip.tsx index ea3ded13..98d5c3ba 100644 --- a/src/page/TimerPage/components/FirstUseToolTip.tsx +++ b/src/page/TimerPage/components/FirstUseToolTip.tsx @@ -1,6 +1,7 @@ import { PropsWithChildren } from 'react'; import { LuKeyboard } from 'react-icons/lu'; import { MdOutlineTimer } from 'react-icons/md'; +import { RiFullscreenExitFill, RiFullscreenFill } from 'react-icons/ri'; // z-index // - 30: Tooltip @@ -68,6 +69,26 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) {
+
+
+ +

전체 화면

+
+ +
+ + 화면 우측 상단 헤더의 전체 화면 버튼 + + 으로 활성화 + + + 화면 우측 상단 헤더의 전체 화면 닫기 버튼 + + 또는 ESC 키를 눌러 전체 화면 비활성화 + +
+
+
); } diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index f3041208..8ef669a3 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -85,6 +85,8 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { return ( ); default: diff --git a/src/page/DebateEndPage/DebateEndPage.tsx b/src/page/DebateEndPage/DebateEndPage.tsx index f631fe5d..c9d8d4c9 100644 --- a/src/page/DebateEndPage/DebateEndPage.tsx +++ b/src/page/DebateEndPage/DebateEndPage.tsx @@ -21,7 +21,7 @@ export default function DebateEndPage() { return (

@@ -56,7 +56,7 @@ export default function DebateEndPage() { {/* 승패투표 카드 */}

-
+ +
diff --git a/src/page/LandingPage/components/TemplateCard.tsx b/src/page/LandingPage/components/TemplateCard.tsx index 3bb8ad7f..0bc90367 100644 --- a/src/page/LandingPage/components/TemplateCard.tsx +++ b/src/page/LandingPage/components/TemplateCard.tsx @@ -33,7 +33,7 @@ export default function TemplateCard({ {`${title} )}
diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 8a408f45..2a2363a7 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -38,13 +38,20 @@ type TimerCreationOption = | 'TIME_NORMAL' | 'BELL'; -type SpeechType = 'OPENING' | 'REBUTTAL' | 'TIMEOUT' | 'CLOSING' | 'CUSTOM'; +type SpeechType = + | 'OPENING' + | 'REBUTTAL' + | 'TIMEOUT' + | 'CLOSING' + | 'CROSS_EXAM' + | 'CUSTOM'; const SPEECH_TYPE_RECORD: Record = { OPENING: '입론', CLOSING: '최종 발언', CUSTOM: '직접 입력', REBUTTAL: '반론', + CROSS_EXAM: '교차 조사', TIMEOUT: '작전 시간', } as const; @@ -122,6 +129,9 @@ export default function TimerCreationContent({ case '작전시간': case '작전 시간': return 'TIMEOUT'; + case '교차조사': + case '교차 조사': + return 'CROSS_EXAM'; default: return 'CUSTOM'; } @@ -234,6 +244,7 @@ export default function TimerCreationContent({ { value: 'OPENING', label: SPEECH_TYPE_RECORD['OPENING'] }, { value: 'REBUTTAL', label: SPEECH_TYPE_RECORD['REBUTTAL'] }, { value: 'TIMEOUT', label: SPEECH_TYPE_RECORD['TIMEOUT'] }, + { value: 'CROSS_EXAM', label: SPEECH_TYPE_RECORD['CROSS_EXAM'] }, { value: 'CLOSING', label: SPEECH_TYPE_RECORD['CLOSING'] }, { value: 'CUSTOM', label: SPEECH_TYPE_RECORD['CUSTOM'] }, ] as const; diff --git a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx index 676f7315..7d89a5cb 100644 --- a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx +++ b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx @@ -160,7 +160,7 @@ export default function TeamSelectionModal({ {(coinState === 'front' || coinState === 'back') && (
+
- {/* 피드백 타이머 카드 */} - + ariaLabel="피드백 타이머로 이동" + /> - {/* 승패투표 카드 */} - + { + if (!tableId) return; // NaN 방지 + mutate(Number(tableId)); + }} + ariaLabel="승패투표 생성 및 진행" + />
diff --git a/src/page/DebateEndPage/components/MenuCard.tsx b/src/page/DebateEndPage/components/MenuCard.tsx new file mode 100644 index 00000000..25ab9995 --- /dev/null +++ b/src/page/DebateEndPage/components/MenuCard.tsx @@ -0,0 +1,58 @@ +// src/components/MenuCard/MenuCard.tsx +import clsx from 'clsx'; + +type MenuCardProps = { + title: string; + description?: string; + imgSrc: string; + imgAlt?: string; + onClick?: () => void; + className?: string; + ariaLabel?: string; +}; + +const titleSize = 'text-lg md:text-xl lg:text-2xl xl:text-title-raw'; +const descSize = 'text-sm md:text-base lg:text-lg xl:text-detail-raw'; + +export default function MenuCard({ + title, + description, + imgSrc, + imgAlt = '', + onClick, + className, + ariaLabel, +}: MenuCardProps) { + return ( + + ); +} diff --git a/src/page/DebateVotePage/DebateVotePage.stories.tsx b/src/page/DebateVotePage/DebateVotePage.stories.tsx new file mode 100644 index 00000000..cd29aada --- /dev/null +++ b/src/page/DebateVotePage/DebateVotePage.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react'; +import DebateVotePage from './DebateVotePage'; + +const meta: Meta = { + title: 'page/DebateVotePage', + component: DebateVotePage, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', // Storybook에서 전체 화면으로 표시 + route: '/table/customize/123/end/vote', + routePattern: '/table/customize/:id/end/vote', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/src/page/DebateVotePage/DebateVotePage.tsx b/src/page/DebateVotePage/DebateVotePage.tsx new file mode 100644 index 00000000..dd08bcb1 --- /dev/null +++ b/src/page/DebateVotePage/DebateVotePage.tsx @@ -0,0 +1,132 @@ +import { useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { QRCodeSVG } from 'qrcode.react'; +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; +import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; +import useFetchEndPoll from '../../hooks/mutations/useFetchEndPoll'; +export default function DebateVotePage() { + const { id: pollIdParam } = useParams(); + const pollId = pollIdParam ? Number(pollIdParam) : NaN; + const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); + const navigate = useNavigate(); + const baseUrl = + import.meta.env.MODE !== 'production' + ? undefined + : import.meta.env.VITE_SHARE_BASE_URL; + const voteUrl = useMemo(() => { + return `${baseUrl}/vote/${pollId}`; + }, [baseUrl, pollId]); + + const handleGoToResult = () => { + navigate(`/table/customize/${pollId}/end/vote/result`); + }; + + const handleGoHome = () => { + navigate('/'); + }; + const { + data, + isLoading: isFetching, + isError: isFetchError, + isRefetching, + refetch, + isRefetchError, + } = useGetPollInfo(pollId, { refetchInterval: 5000, enabled: isValidPollId }); + const { mutate } = useFetchEndPoll(handleGoToResult); + + const participants = data?.voterNames; + const isLoading = isFetching || isRefetching; + const isError = isFetchError || isRefetchError; + + if (isError) { + return ( + + + refetch()} /> + + + ); + } + if (!isValidPollId) { + return ( + + + navigate('/')}> + 유효하지 않은 투표 링크입니다. + + + + ); + } + return ( + + +
+
+

+ 승패투표 +

+
+ +
+
+

스캔해 주세요!

+
+
+ +
+
+
+
+
+

+ 참여자 + + ({participants?.length ?? 0}) + +

+
+
+ {!isLoading && participants && participants.length === 0 && ( +

등록된 토론자가 없어요.

+ )} + {!isLoading && participants && participants.length > 0 && ( +
    + {participants.map((name) => ( +
  • + {name} +
  • + ))} +
+ )} +
+
+
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.stories.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.stories.tsx new file mode 100644 index 00000000..d4bf298c --- /dev/null +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react'; +import DebateVoteResultPage from './DebateVoteResultPage'; + +const meta: Meta = { + title: 'page/DebateVoteResultPage', + component: DebateVoteResultPage, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', // Storybook에서 전체 화면으로 표시 + route: '/table/customize/123/end/vote/result', + routePattern: '/table/customize/:id/end/vote/result', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx new file mode 100644 index 00000000..69c19682 --- /dev/null +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -0,0 +1,134 @@ +import { useNavigate, useParams } from 'react-router-dom'; + +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import WinnerCard from './components/WinnerCard'; +import { useModal } from '../../hooks/useModal'; +import VoteDetailResult from './components/VoteDetailResult'; +import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; +import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; +import { TeamKey } from '../../type/type'; +export default function DebateVoteResultPage() { + const { id: pollIdParam } = useParams(); + + const pollId = pollIdParam ? Number(pollIdParam) : NaN; + const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); + const navigate = useNavigate(); + + const { + data, + isLoading: isFetching, + isError: isFetchError, + isRefetching, + refetch, + isRefetchError, + } = useGetPollInfo(pollId, { enabled: isValidPollId }); + const handleGoHome = () => { + navigate('/'); + }; + const isLoading = isFetching || isRefetching; + const isError = isFetchError || isRefetchError; + const { openModal, ModalWrapper } = useModal(); + + const getWinner = (result: { + prosTeamName: string; + consTeamName: string; + prosCount: number; + consCount: number; + }): { teamKey: TeamKey | null; teamName: string } => { + const { prosTeamName, consTeamName, prosCount, consCount } = result; + + if (prosCount > consCount) { + return { + teamKey: 'PROS', + teamName: prosTeamName, + }; + } else if (consCount > prosCount) { + return { + teamKey: 'CONS', + teamName: consTeamName, + }; + } else { + return { + teamKey: null, + teamName: '무승부', + }; + } + }; + + if (!isValidPollId) { + return ( + + + navigate('/')}> + 유효하지 않은 투표 결과 링크입니다. + + + + ); + } + if (isError) { + return ( + + + refetch()} /> + + + ); + } + const { teamKey, teamName } = getWinner({ + prosTeamName: data?.prosTeamName || '찬성팀', + consTeamName: data?.consTeamName || '반대팀', + prosCount: data?.prosCount || 0, + consCount: data?.consCount || 0, + }); + return ( + + +
+
+

+ 승패투표 +

+
+ +
+ +
+ + +
+ + +
+
+
+
+ + + +
+ ); +} diff --git a/src/page/DebateVoteResultPage/components/AnimatedCounter.tsx b/src/page/DebateVoteResultPage/components/AnimatedCounter.tsx new file mode 100644 index 00000000..ec0c5929 --- /dev/null +++ b/src/page/DebateVoteResultPage/components/AnimatedCounter.tsx @@ -0,0 +1,31 @@ +// components/CountUp.tsx +import { useEffect, useState } from 'react'; +import { animate } from 'framer-motion'; + +type AnimatedCounterProps = { + to: number; + duration?: number; // 초 + delay?: number; // 초 + className?: string; +}; + +export default function AnimatedCounter({ + to, + duration = 1.2, + delay = 0, + className, +}: AnimatedCounterProps) { + const [value, setValue] = useState(0); + + useEffect(() => { + const controls = animate(0, to, { + duration, + delay, + ease: 'easeOut', + onUpdate: (latest) => setValue(Math.round(latest)), + }); + return () => controls.stop(); + }, [to, duration, delay]); + + return {value}; +} diff --git a/src/page/DebateVoteResultPage/components/VoteBar.tsx b/src/page/DebateVoteResultPage/components/VoteBar.tsx new file mode 100644 index 00000000..2a6c6439 --- /dev/null +++ b/src/page/DebateVoteResultPage/components/VoteBar.tsx @@ -0,0 +1,60 @@ +import { motion } from 'framer-motion'; +import AnimatedCounter from './AnimatedCounter'; +import { TEAM_STYLE, TeamKey } from '../../../type/type'; + +type VoteBarProps = { + teamKey: TeamKey; // "PROS" | "CONS" + teamName: string; // "단비" / "청춘예찬" + count: number; // 득표 수 + total: number; // 전체 인원 + heightClass?: string; // h-20 등 높이 조절용 +}; + +export default function VoteBar({ + teamKey, + teamName, + count, + total, + heightClass = 'h-20', +}: VoteBarProps) { + const style = TEAM_STYLE[teamKey]; + const percentage = total > 0 ? (count / total) * 100 : 0; + const sideLabel = teamKey === 'PROS' ? '찬성팀' : '반대팀'; + + // 배경 바 색상은 좀 더 투명하게 + const barTone = + teamKey === 'PROS' + ? 'bg-[#C2E8FF]' // 찬성(파랑) + : 'bg-[#FFC7D3]'; // 반대(빨강) + + return ( +
+ {/* 배경 퍼센트바 */} + + + {/* 텍스트 영역 */} +
+
+ + {sideLabel} + + + {teamName} + +
+ +
+ 명 +
+
+
+ ); +} diff --git a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx new file mode 100644 index 00000000..64eae086 --- /dev/null +++ b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx @@ -0,0 +1,58 @@ +// pages/VoteDetailResult.tsx +import { motion } from 'framer-motion'; +import VoteBar from './VoteBar'; + +type VoteDetailResultProps = { + onGoHome?: () => void; + pros: { name: string; count: number }; + cons: { name: string; count: number }; +}; + +export default function VoteDetailResult({ + onGoHome, + pros, + cons, +}: VoteDetailResultProps) { + return ( +
+ + {/* 내용 */} +
+

+ 투표 세부 결과 +

+ +
+ + +
+
+ + {/* 하단 CTA 바 */} +
+ +
+
+
+ ); +} diff --git a/src/page/DebateVoteResultPage/components/WinnerCard.tsx b/src/page/DebateVoteResultPage/components/WinnerCard.tsx new file mode 100644 index 00000000..61c72528 --- /dev/null +++ b/src/page/DebateVoteResultPage/components/WinnerCard.tsx @@ -0,0 +1,54 @@ +import crown from '../../../assets/debateEnd/crown.svg'; +import { TEAM_STYLE, TeamKey } from '../../../type/type'; +import clsx from 'clsx'; + +interface WinnerCardProps { + teamkey: TeamKey | null; // "PROS" | "CONS" | null + teamName: string; // 예: "단비" 또는 "무승부" +} + +export default function WinnerCard({ teamkey, teamName }: WinnerCardProps) { + const style = teamkey ? TEAM_STYLE[teamkey] : null; + const sideLabel = + teamkey === 'PROS' ? '찬성팀' : teamkey === 'CONS' ? '반대팀' : '무승부'; + + return ( +
+ {/* 카드 */} +
+
+

+ {sideLabel} +

+

+ {teamName} +

+
+
+ + {/* 왕관 — 무승부일 때는 표시 안 함 */} + {teamkey && ( +
+ 왕관 +
+ )} +
+ ); +} diff --git a/src/page/VoteCompletePage/VoteCompletePage.stories.tsx b/src/page/VoteCompletePage/VoteCompletePage.stories.tsx new file mode 100644 index 00000000..dba4b962 --- /dev/null +++ b/src/page/VoteCompletePage/VoteCompletePage.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react'; +import VoteCompletePage from './VoteCompletePage'; + +const meta: Meta = { + title: 'page/VoteCompletePage', + component: VoteCompletePage, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', // Storybook에서 전체 화면으로 표시 + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/src/page/VoteCompletePage/VoteCompletePage.tsx b/src/page/VoteCompletePage/VoteCompletePage.tsx new file mode 100644 index 00000000..8e5ddcf1 --- /dev/null +++ b/src/page/VoteCompletePage/VoteCompletePage.tsx @@ -0,0 +1,25 @@ +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import CheckBox from '../../components/icons/CheckBox'; + +export default function VoteCompletePage() { + return ( + + +
+
+ {/* 체크 아이콘 배지 */} + + {/* 완료 메시지 */} +

+ 투표가 완료되었습니다. +

+
+
+
+
+ ); +} diff --git a/src/page/VoteParticipationPage/VoteParticipationPage.stories.tsx b/src/page/VoteParticipationPage/VoteParticipationPage.stories.tsx new file mode 100644 index 00000000..bb08e784 --- /dev/null +++ b/src/page/VoteParticipationPage/VoteParticipationPage.stories.tsx @@ -0,0 +1,24 @@ +// VoteCompletePage.stories.tsx +import { Meta, StoryObj } from '@storybook/react'; +import VoteParticipationPage from './VoteParticipationPage'; + +const meta: Meta = { + title: 'page/VoteParticipationPage', + component: VoteParticipationPage, + parameters: { + layout: 'fullscreen', + route: '/vote/123', + routePattern: '/vote/:id', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/src/page/VoteParticipationPage/VoteParticipationPage.tsx b/src/page/VoteParticipationPage/VoteParticipationPage.tsx new file mode 100644 index 00000000..c786aabc --- /dev/null +++ b/src/page/VoteParticipationPage/VoteParticipationPage.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import clsx from 'clsx'; +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import ClearableInput from '../../components/ClearableInput/ClearableInput'; +import { useModal } from '../../hooks/useModal'; +import DialogModal from '../../components/DialogModal/DialogModal'; +import VoteTeamButton from './components/VoteTeamButton'; +import { useGetVoterPollInfo } from '../../hooks/query/useGetVoterPollInfo'; +import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; +import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; +import usePostVoterPollInfo from '../../hooks/mutations/usePostVoterPollInfo'; +import { TeamKey } from '../../type/type'; + +const TEAM_LABEL = { + PROS: '찬성팀', + CONS: '반대팀', +} as const; + +export default function VoteParticipationPage() { + const { id: pollIdParam } = useParams(); + const navigate = useNavigate(); + // 1) pollId 파싱 + 유효성 체크 + const pollId = pollIdParam ? Number(pollIdParam) : NaN; + const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); + + const [participantName, setParticipantName] = useState(''); + const [selectedTeam, setSelectedTeam] = useState(null); + + const { + data, + isLoading: isFetching, + isError: isFetchError, + isRefetching, + refetch, + isRefetchError, + } = useGetVoterPollInfo(pollId, { enabled: isValidPollId }); + const { openModal, closeModal, ModalWrapper } = useModal(); + + const isSubmitDisabled = + participantName.trim().length === 0 || selectedTeam === null; + + const { mutate } = usePostVoterPollInfo(() => navigate(`/vote/end`)); + + const handleSubmit = () => { + if (isSubmitDisabled) return; + mutate({ + pollId: pollId, + voterInfo: { + name: participantName.trim(), + participateCode: data?.participateCode ?? '', + team: selectedTeam, + }, + }); + }; + + const isLoading = isFetching || isRefetching; + const isError = isFetchError || isRefetchError; + + if (isError) { + return ( + + + refetch()} /> + + + ); + } + + if (!isValidPollId) { + return ( + + + navigate('/')}> + 유효하지 않은 투표 링크입니다. + + + + ); + } + + return ( + + + {isLoading && } + {!isLoading && ( +
+
+
+

+ 승패투표 +

+
+ +
+
+ + setParticipantName(e.target.value)} + onClear={() => setParticipantName('')} + /> +
+ +
+ setSelectedTeam('PROS')} + /> + setSelectedTeam('CONS')} + /> +
+
+
+
+ )} + + + + +
+ + closeModal(), + isBold: true, + }} + right={{ + text: '제출하기', + onClick: () => { + handleSubmit(); + closeModal(); + }, + isBold: true, + }} + > +
+

투표를 제출하시겠습니까?

+

(제출 후에는 변경이 불가능 합니다.)

+
+
+
+
+ ); +} diff --git a/src/page/VoteParticipationPage/components/VoteTeamButton.tsx b/src/page/VoteParticipationPage/components/VoteTeamButton.tsx new file mode 100644 index 00000000..62f90236 --- /dev/null +++ b/src/page/VoteParticipationPage/components/VoteTeamButton.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; +import { TEAM_STYLE, TeamKey } from '../../../type/type'; + +interface VoteTeamButtonProps { + label: string; + name: string; + teamkey: TeamKey; + selectedTeam: TeamKey | null; + isSelected: boolean; + onSelect: () => void; +} + +export default function VoteTeamButton({ + label, + name, + teamkey, + isSelected, + selectedTeam, + onSelect, +}: VoteTeamButtonProps) { + const style = TEAM_STYLE[teamkey]; + + return ( + + ); +} diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 87e3f1dd..9bf5a5d5 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -13,6 +13,10 @@ import FeedbackTimerPage from '../page/TimerPage/FeedbackTimerPage'; import LandingPage from '../page/LandingPage/LandingPage'; import TableSharingPage from '../page/TableSharingPage/TableSharingPage'; import DebateEndPage from '../page/DebateEndPage/DebateEndPage'; +import DebateVotePage from '../page/DebateVotePage/DebateVotePage'; +import VoteParticipationPage from '../page/VoteParticipationPage/VoteParticipationPage'; +import VoteCompletePage from '../page/VoteCompletePage/VoteCompletePage'; +import DebateVoteResultPage from '../page/DebateVoteResultPage/DebateVoteResultPage'; const routesConfig = [ { @@ -50,6 +54,26 @@ const routesConfig = [ element: , requiresAuth: true, }, + { + path: '/table/customize/:id/end/vote', + element: , + requiresAuth: true, + }, + { + path: '/table/customize/:id/end/vote/result', + element: , + requiresAuth: true, + }, + { + path: '/vote/:id', + element: , + requiresAuth: false, + }, + { + path: '/vote/end', + element: , + requiresAuth: false, + }, { path: '/oauth', element: , diff --git a/src/type/type.ts b/src/type/type.ts index b47c0f03..f96e5f7e 100644 --- a/src/type/type.ts +++ b/src/type/type.ts @@ -65,7 +65,24 @@ export interface DebateTableData { table: TimeBoxInfo[]; } -// ===== 배경 색상 상태 타입 및 컬러 맵 정의 ===== +export interface BasePollInfo { + status: 'PROGRESS' | 'DONE'; + prosTeamName: string; + consTeamName: string; +} +export interface PollInfo extends BasePollInfo { + totalCount: number; + prosCount: number; + consCount: number; + voterNames: string[]; +} + +export interface VoterPollInfo { + name: string; + participateCode: string; + team: 'PROS' | 'CONS'; +} + export type TimerBGState = 'default' | 'warning' | 'danger' | 'expired'; export const bgColorMap: Record = { default: '', @@ -86,3 +103,25 @@ export type DebateTemplate = { actions: Action[]; className?: string; // 카드의 추가 className이 필요하면 사용 }; + +type TeamStyleConfig = { + baseBg: string; + baseBorder: string; + label: string; + name: string; +}; +export type TeamKey = TimeBasedStance; +export const TEAM_STYLE: Record = { + PROS: { + baseBg: 'bg-[#C2E8FF]', + baseBorder: 'border-[#1E91D6]', + label: 'text-[#1E91D6]', + name: 'text-[#1E91D6]', + }, + CONS: { + baseBg: 'bg-[#FFC7D3]', + baseBorder: 'border-[#E14666]', + label: 'text-[#E14666]', + name: 'text-[#E14666]', + }, +}; diff --git a/tailwind.config.js b/tailwind.config.js index a32beb13..2c477a6e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -31,6 +31,9 @@ export default { }, }, }, + backgroundImage: { + brandBackground: 'radial-gradient(80% 80% at 50% 50%, #fecd4c21 0%, #ffffff42 100%)', + }, animation: { rotate: 'rotate 5s linear infinite', gradient: 'gradient 10s ease infinite', From 2575c714c4b52ad8e808e67ae2fe3abb7dc499cd Mon Sep 17 00:00:00 2001 From: jaeml06 Date: Sun, 19 Oct 2025 16:28:29 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=AA=A9=ED=8F=AC=EC=B9=B4?= =?UTF-8?q?=ED=86=A8=EB=A6=AD=EB=8C=80=ED=95=99=EA=B5=90=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/template_logo/mcu.png | Bin 0 -> 14474 bytes src/constants/debate_template.ts | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/assets/template_logo/mcu.png diff --git a/src/assets/template_logo/mcu.png b/src/assets/template_logo/mcu.png new file mode 100644 index 0000000000000000000000000000000000000000..9ac435287b0873a671837454b5db05420ffec58a GIT binary patch literal 14474 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
`Q+4jbSB>;)Ytbytm0(>gc}c;HX! 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