diff --git a/src/apis/apis/poll.ts b/src/apis/apis/poll.ts new file mode 100644 index 00000000..d66d31dd --- /dev/null +++ b/src/apis/apis/poll.ts @@ -0,0 +1,99 @@ +import { VoterPollInfo } from '../../type/type'; +import { ApiUrl } from '../endpoints'; +import { request } from '../primitives'; +import { + GetPollResponseType, + GetVoterPollInfoResponseType, + PatchPollResponseType, + PostPollResponseType, + PostVoterPollInfoResponseType, +} from '../responses/poll'; + +// Template +/* +export async function apiFunc( + +): Promise { + const requestUrl: string = ApiUrl. + const response = await request( + method, + requestUrl, + data, + params, + ); + + return response.data; +} +*/ + +// POST /api/polls/{tableId} +export async function postPoll(tableId: number): Promise { + const requestUrl: string = ApiUrl.poll; + const response = await request( + 'POST', + requestUrl + `/${tableId}`, + null, + null, + ); + + return response.data; +} + +// GET /api/polls/{pollId} +export async function getPollInfo( + pollId: number, +): Promise { + const requestUrl: string = ApiUrl.poll; + const response = await request( + 'GET', + requestUrl + `/${pollId}`, + null, + null, + ); + return response.data; +} + +// PATCH /api/polls/{pollId} +export async function patchEndPoll( + pollId: number, +): Promise { + const requestUrl: string = ApiUrl.poll; + const response = await request( + 'PATCH', + requestUrl + `/${pollId}`, + null, + null, + ); + return response.data; +} + +// GET /api/polls/{pollId}/votes +export async function getVoterPollInfo( + pollId: number, +): Promise { + const requestUrl: string = ApiUrl.poll; + const response = await request( + 'GET', + requestUrl + `/${pollId}/votes`, + null, + null, + ); + + return response.data; +} + +// POST /api/polls/{pollId}/votes +export async function postVoterPollInfo( + pollId: number, + voterInfo: VoterPollInfo, +): Promise { + const requestUrl: string = ApiUrl.poll; + const response = await request( + 'POST', + requestUrl + `/${pollId}/votes`, + voterInfo, + null, + ); + + return response.data; +} diff --git a/src/apis/endpoints.ts b/src/apis/endpoints.ts index 95241a7c..7589d94c 100644 --- a/src/apis/endpoints.ts +++ b/src/apis/endpoints.ts @@ -10,4 +10,5 @@ export const ApiUrl = { table: makeUrl('/table'), parliamentary: makeUrl('/table/parliamentary'), customize: makeUrl('/table/customize'), + poll: makeUrl('/polls'), }; diff --git a/src/apis/responses/poll.ts b/src/apis/responses/poll.ts new file mode 100644 index 00000000..01295050 --- /dev/null +++ b/src/apis/responses/poll.ts @@ -0,0 +1,28 @@ +import { BasePollInfo, PollInfo, VoterPollInfo } from '../../type/type'; + +// POST /api/polls/{tableId} +export interface PostPollResponseType extends BasePollInfo { + id: number; +} + +// GET /api/polls/{pollId} +export interface GetPollResponseType extends PollInfo { + id: number; +} + +// PATCH /api/polls/{pollId} +export interface PatchPollResponseType extends PollInfo { + id: number; +} + +// GET /api/polls/{pollId}/votes +export interface GetVoterPollInfoResponseType extends PollInfo { + id: number; + participateCode: string; +} + +// POST /api/polls/{pollId}/votes + +export interface PostVoterPollInfoResponseType extends VoterPollInfo { + id: number; +} diff --git a/src/assets/debateEnd/crown.svg b/src/assets/debateEnd/crown.svg new file mode 100644 index 00000000..4c356156 --- /dev/null +++ b/src/assets/debateEnd/crown.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/template_logo/kondae_time.png b/src/assets/template_logo/kondae_time.png index ed4dcee0..f75c2b84 100644 Binary files a/src/assets/template_logo/kondae_time.png and b/src/assets/template_logo/kondae_time.png differ diff --git a/src/assets/template_logo/mcu.png b/src/assets/template_logo/mcu.png new file mode 100644 index 00000000..9ac43528 Binary files /dev/null and b/src/assets/template_logo/mcu.png differ diff --git a/src/components/GoToHomeButton/GoToHomeButton.tsx b/src/components/GoToHomeButton/GoToHomeButton.tsx index 822ec6a3..5d80226b 100644 --- a/src/components/GoToHomeButton/GoToHomeButton.tsx +++ b/src/components/GoToHomeButton/GoToHomeButton.tsx @@ -12,9 +12,9 @@ export default function GoToHomeButton() { type="button" aria-label="홈으로 돌아가기" onClick={handleClick} - className="flex h-[72px] w-[492px] items-center justify-center gap-[12px] rounded-full border-[2px] border-default-disabled/hover bg-default-white px-[16px] py-[11px] font-semibold text-default-black opacity-80 transition-colors duration-200 hover:bg-default-disabled/hover" + className="button enabled neutral flex w-[492px] flex-row space-x-[12px] rounded-full p-[24px]" > - 홈으로 돌아가기 → + 홈으로 돌아가기 → ); } diff --git a/src/components/icons/CheckBox.tsx b/src/components/icons/CheckBox.tsx new file mode 100644 index 00000000..14fb5712 --- /dev/null +++ b/src/components/icons/CheckBox.tsx @@ -0,0 +1,37 @@ +import DTCheck from './Check'; +import clsx from 'clsx'; + +interface CheckBoxProps { + checked?: boolean; + size?: number | string; + className?: string; +} + +/** + *text-* 클래스가 포함되면 자동으로 DTCheck로 전달 + */ +export default function CheckBox({ + checked = false, + size = 24, + className = '', +}: CheckBoxProps) { + // text-로 시작하는 클래스만 추출 + const textClass = + className.split(' ').find((c) => c.startsWith('text-')) ?? ''; + + return ( +
+ {checked && } +
+ ); +} 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/constants/debate_template.ts b/src/constants/debate_template.ts index 4c379cad..58495480 100644 --- a/src/constants/debate_template.ts +++ b/src/constants/debate_template.ts @@ -10,6 +10,7 @@ import seobangjeongto from '../assets/template_logo/seobangjeongto.png'; import osansi from '../assets/template_logo/osansi.png'; import nogotte from '../assets/template_logo/nogotte.png'; import kogito from '../assets/template_logo/kogito.png'; +import mcu from '../assets/template_logo/mcu.png'; import { DebateTemplate } from '../type/type'; function createTableShareUrl(encodeData: string): string { return `${import.meta.env.VITE_SHARE_BASE_URL}/share?data=${encodeData}`; @@ -189,6 +190,25 @@ export const DEBATE_TEMPLATE: DebateTemplateList = { }, ], }, + { + title: '목포카톨릭대', + subtitle: '', + logoSrc: mcu, + actions: [ + { + label: 'A형 토론(20분)', + href: createTableShareUrl( + 'eJzNkkFLwzAUx79KyUmhYplTsLdu9jBw7Vi7iyKSbdlW7NLSTlTGwMO2k6AXZYcxpgdRmVBBRT9T0%2B%2Fgy6rSycTb8Jb8wnv%2FX17SRlYVyen1NRFZtOYguY0obhIko%2FDhPjqfsPfnqH8X3j6GZ6fCiqBEg0sh6o%2FD6%2FFSSgpfu8tIRK0TlxdkS4ap53M7KiBcJ7SKAcLa9RzfJLipxX1ZMGHdJ%2BAVhyZ5GAwgA%2FgR9qhF6xli20iuYdsnIqpZ1PIbCdSBWFy2oXC3jfwWphXeo1DUDejgu4RUGmasxUY9sAVado4%2FkaYX88o2N7d49KYEh9PW9NC2Y1ogHnf7gQyX4ANw%2B8I%2B3xOP37MjJjyyuvYvPDS1ZBanCbMqwWQVxi2w0QUbjuPnnBEzc3l1P6MY6ta3XBz0q146Jc2x25ASbrzq7ym9DNlNTwiDIbt6W%2Byw5n6exersdT4Avp5JzQ%3D%3D', + ), + }, + { + label: 'B형 토론(25분)', + href: createTableShareUrl( + 'eJyrVspMUbIyMTXRUcrMS8tXsqpWykvMTVWyUnq9auXb%2FjVvdm5527bi9bK1r3saFHQVnN7OmKrwtm3B64ULNIxMX29r0VTSUSqpLABpcA4NDvH39YxyBQolpqfmpSQCBYHsgqL84pDUxFw%2FiLlvNqx507IRKJ6cn4cs%2FnrDDKAdQPHyxKK8zLx0p9ScHCWrtMSc4lQdpbTMvMziDCShWqC1iUk5QI3R1UrFJYl5ySAzAoL8g4EmFBekpiZnhECc9WZeK9C1QNGk%2FAqokJ9%2FkK%2BjD8jlmSCrLQ2AkmCj80pzciCiAalFILehCQUXpCZmA90GEy4G8VOLQP6s1UFyh7O%2F36Bwh59raEgQ2AY0p0x4M2cBJBpRHBTi6esa7%2BQY7OoCdxTEApzOMjU3wOIqMwMkN4F0EQ6drXPeLGpVeL1hzptpO%2BgbSFgTDX2dE1sLAJl%2BRRA%3D', + ), + }, + ], + }, ], THREE: [ { diff --git a/src/hooks/mutations/useCreatePoll.ts b/src/hooks/mutations/useCreatePoll.ts new file mode 100644 index 00000000..173adbee --- /dev/null +++ b/src/hooks/mutations/useCreatePoll.ts @@ -0,0 +1,12 @@ +import { postPoll } from '../../apis/apis/poll'; +import { PostPollResponseType } from '../../apis/responses/poll'; +import { usePreventDuplicateMutation } from './usePreventDuplicateMutation'; + +export default function usePostPoll(onSuccess: (id: number) => void) { + return usePreventDuplicateMutation({ + mutationFn: (id: number) => postPoll(id), + onSuccess: (response: PostPollResponseType) => { + onSuccess(response.id); + }, + }); +} diff --git a/src/hooks/mutations/useFetchEndPoll.ts b/src/hooks/mutations/useFetchEndPoll.ts new file mode 100644 index 00000000..53574760 --- /dev/null +++ b/src/hooks/mutations/useFetchEndPoll.ts @@ -0,0 +1,12 @@ +import { patchEndPoll } from '../../apis/apis/poll'; +import { PatchPollResponseType } from '../../apis/responses/poll'; +import { usePreventDuplicateMutation } from './usePreventDuplicateMutation'; + +export default function useFetchEndPoll(onSuccess: (id: number) => void) { + return usePreventDuplicateMutation({ + mutationFn: (pollId: number) => patchEndPoll(pollId), + onSuccess: (response: PatchPollResponseType) => { + onSuccess(response.id); + }, + }); +} diff --git a/src/hooks/mutations/usePostVoterPollInfo.ts b/src/hooks/mutations/usePostVoterPollInfo.ts new file mode 100644 index 00000000..3e19e5e6 --- /dev/null +++ b/src/hooks/mutations/usePostVoterPollInfo.ts @@ -0,0 +1,18 @@ +import { postVoterPollInfo } from '../../apis/apis/poll'; +import { VoterPollInfo } from '../../type/type'; +import { usePreventDuplicateMutation } from './usePreventDuplicateMutation'; + +export default function usePostVoterPollInfo(onSuccess: () => void) { + return usePreventDuplicateMutation({ + mutationFn: ({ + pollId, + voterInfo, + }: { + pollId: number; + voterInfo: VoterPollInfo; + }) => postVoterPollInfo(pollId, voterInfo), + onSuccess: () => { + onSuccess(); + }, + }); +} diff --git a/src/hooks/query/useGetPollInfo.ts b/src/hooks/query/useGetPollInfo.ts new file mode 100644 index 00000000..ab5948c1 --- /dev/null +++ b/src/hooks/query/useGetPollInfo.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPollInfo } from '../../apis/apis/poll'; +import { GetPollResponseType } from '../../apis/responses/poll'; + +export function useGetPollInfo( + pollId: number, + options?: { refetchInterval?: number | false; enabled?: boolean }, +) { + return useQuery({ + queryKey: ['Poll', pollId], + queryFn: () => getPollInfo(pollId), + refetchInterval: options?.refetchInterval ?? false, + enabled: options?.enabled, + }); +} diff --git a/src/hooks/query/useGetVoterPollInfo.ts b/src/hooks/query/useGetVoterPollInfo.ts new file mode 100644 index 00000000..d10efbd5 --- /dev/null +++ b/src/hooks/query/useGetVoterPollInfo.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getVoterPollInfo } from '../../apis/apis/poll'; +import { GetVoterPollInfoResponseType } from '../../apis/responses/poll'; + +export function useGetVoterPollInfo( + pollId: number, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: ['VoterPoll', pollId], + queryFn: () => getVoterPollInfo(pollId), + enabled: options?.enabled, + }); +} 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/footer/StickyFooterWrapper.tsx b/src/layout/components/footer/StickyFooterWrapper.tsx index 3a52d507..4fdbb240 100644 --- a/src/layout/components/footer/StickyFooterWrapper.tsx +++ b/src/layout/components/footer/StickyFooterWrapper.tsx @@ -4,7 +4,7 @@ export default function StickyFooterWrapper(props: PropsWithChildren) { const { children } = props; return ( -
+
{children}
); diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index c243f946..8ef669a3 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,16 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { case 'home': return ( ); default: diff --git a/src/mocks/handlers/global.ts b/src/mocks/handlers/global.ts index 89ffcf57..f43ded4d 100644 --- a/src/mocks/handlers/global.ts +++ b/src/mocks/handlers/global.ts @@ -1,4 +1,9 @@ import { customizeHandlers } from './customize'; import { memberHandlers } from './member'; +import { pollHandlers } from './poll'; -export const allHandlers = [...memberHandlers, ...customizeHandlers]; +export const allHandlers = [ + ...memberHandlers, + ...customizeHandlers, + ...pollHandlers, +]; diff --git a/src/mocks/handlers/poll.ts b/src/mocks/handlers/poll.ts new file mode 100644 index 00000000..b7b9b457 --- /dev/null +++ b/src/mocks/handlers/poll.ts @@ -0,0 +1,93 @@ +import { http, HttpResponse } from 'msw'; +import { ApiUrl } from '../../apis/endpoints'; +import { + PatchPollResponseType, + PostPollResponseType, + PostVoterPollInfoResponseType, +} from '../../apis/responses/poll'; + +export const pollHandlers = [ + // GET /api/polls/:pollId + http.get(ApiUrl.poll + '/:pollId', ({ params }) => { + const { pollId } = params; + console.log(`# pollId = ${pollId}`); + + return HttpResponse.json({ + id: 7, + status: 'PROGRESS', + prosTeamName: '찬성', + consTeamName: '반대', + totalCount: 1, + prosCount: 1, + consCount: 0, + voterNames: ['ㅇㄹㄴ', 'ㅁㄴㅇ'], + }); + }), + + // POST /api/polls/{tableId} + http.post(ApiUrl.poll + '/:tableId', async ({ request }) => { + const result = (await request.json()) as PostPollResponseType; + console.log( + `# pollId = ${result?.id}, prosTeamName = ${result?.prosTeamName}, consTeamName = ${result?.consTeamName}`, + ); + return HttpResponse.json({ + id: 7, + status: 'PROGRESS', + prosTeamName: '찬성', + consTeamName: '반대', + }); + }), + + // PATCH /api/polls/{pollId} + http.patch(ApiUrl.poll + '/:pollId', async ({ request, params }) => { + const result = (await request.json()) as PatchPollResponseType; + const { pollId } = params; + console.log( + `pollId = ${pollId}, status = ${result?.status}, prosTeamName = ${result?.prosTeamName}, consTeamName = ${result?.consTeamName}`, + ); + + return HttpResponse.json({ + id: 7, + status: 'DONE', + prosTeamName: '찬성', + consTeamName: '반대', + totalCount: 1, + prosCount: 1, + consCount: 0, + voterNames: ['ㅇㄹㄴ', 'ㅁㄴㅇ'], + }); + }), + + // GET /api/polls/{pollId}/votes + http.get(ApiUrl.poll + `/:pollId/votes`, ({ params }) => { + const { pollId } = params; + console.log(`# pollId = ${pollId}`); + + return HttpResponse.json({ + id: 7, + status: 'PROGRESS', + prosTeamName: '찬성', + consTeamName: '반대', + participateCode: '494bcec7-f8e8-4e96-8922-511cd7114a07', + totalCount: 1, + prosCount: 1, + consCount: 0, + }); + }), + + // POST /api/polls/{pollId}/votes + http.post(ApiUrl.poll + '/:pollId/votes', async ({ request, params }) => { + const { pollId } = params; + const result = (await request.json()) as PostVoterPollInfoResponseType; + console.log( + `# pollId = ${pollId}, name = ${result?.name}, participateCode = ${result?.participateCode}, team = ${result?.team}`, + ); + + return HttpResponse.json({ + id: 7, + name: 'ㅇㄹㄴ', + participateCode: 'string', + team: 'PROS', + }); + }), +]; diff --git a/src/page/DebateEndPage/DebateEndPage.stories.tsx b/src/page/DebateEndPage/DebateEndPage.stories.tsx new file mode 100644 index 00000000..e7d27b01 --- /dev/null +++ b/src/page/DebateEndPage/DebateEndPage.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react'; +import DebateEndPage from './DebateEndPage'; + +const meta: Meta = { + title: 'page/DebateEndPage', + component: DebateEndPage, + 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/DebateEndPage/DebateEndPage.tsx b/src/page/DebateEndPage/DebateEndPage.tsx index f631fe5d..bb535cf2 100644 --- a/src/page/DebateEndPage/DebateEndPage.tsx +++ b/src/page/DebateEndPage/DebateEndPage.tsx @@ -4,6 +4,8 @@ import clapImage from '../../assets/debateEnd/clap.png'; import feedbackTimerImage from '../../assets/debateEnd/feedback_timer.png'; import voteStampImage from '../../assets/debateEnd/vote_stamp.png'; import GoToHomeButton from '../../components/GoToHomeButton/GoToHomeButton'; +import usePostPoll from '../../hooks/mutations/useCreatePoll'; +import MenuCard from './components/MenuCard'; export default function DebateEndPage() { const { id: tableId } = useParams(); @@ -13,6 +15,10 @@ export default function DebateEndPage() { navigate(`/table/customize/${tableId}/end/feedback`); }; + const handleVoteClick = (pollId: number) => { + navigate(`/table/customize/${pollId}/end/vote`); + }; + const { mutate } = usePostPoll(handleVoteClick); const backgroundStyle = { background: 'radial-gradient(50% 50% at 50% 50%, #fecd4c21 0%, #ffffff42 100%)', @@ -21,7 +27,7 @@ export default function DebateEndPage() { return (

@@ -35,51 +41,29 @@ export default function DebateEndPage() {

- {/* 피드백 타이머 카드 */} - + 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/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') && (
+ + @@ -98,7 +124,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/FeedbackTimer.tsx b/src/page/TimerPage/components/FeedbackTimer.tsx index f997cfa3..21981ff0 100644 --- a/src/page/TimerPage/components/FeedbackTimer.tsx +++ b/src/page/TimerPage/components/FeedbackTimer.tsx @@ -38,7 +38,7 @@ export default function FeedbackTimer({ timer !== null && defaultTimer > 0 ? ((defaultTimer - timer) / defaultTimer) * 100 : 0; - const progressMotionValue = useCircularTimerAnimation(rawProgress); + const progressMotionValue = useCircularTimerAnimation(rawProgress, isRunning); const breakpoint = useBreakpoint(); const getStrokeWidth = () => { 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 키를 눌러 전체 화면 비활성화 + +
+
+
+ + + + 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/fullscreen.d.ts b/src/type/fullscreen.d.ts new file mode 100644 index 00000000..b7137659 --- /dev/null +++ b/src/type/fullscreen.d.ts @@ -0,0 +1,17 @@ +/** + * 브라우저별 접두사가 붙은 전체 화면 속성을 포함하는 사용자 정의 인터페이스 + */ +export interface DocumentWithFullscreen extends Document { + mozCancelFullScreen?: () => Promise; + webkitExitFullscreen?: () => Promise; + msExitFullscreen?: () => Promise; + mozFullScreenElement?: Element; + webkitFullscreenElement?: Element; + msFullscreenElement?: Element; +} + +export interface HTMLElementWithFullscreen extends HTMLElement { + mozRequestFullScreen?: () => Promise; + webkitRequestFullscreen?: () => Promise; + msRequestFullscreen?: () => Promise; +} 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',