) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ {error &&
{error.message}
}
+
+
(
+
+
+
{field.value}
+
+ {open && (
+
+ {options.map((option) => (
+ - {
+ field.onChange(option);
+ setOpen(false);
+ }}
+ className={`flex ${field.value === option ? 'bg-[#8b8674]' : 'bg-[#e1e1d7]'}`}
+ >
+
{option}
+
+
+ ))}
+
+ )}
+
+ )}
+ />
+
+
+ );
+}
diff --git a/src/components/features/vote/candidate-card.tsx b/src/components/features/vote/candidate-card.tsx
new file mode 100644
index 0000000..73e995b
--- /dev/null
+++ b/src/components/features/vote/candidate-card.tsx
@@ -0,0 +1,33 @@
+export default function CandidateCard({
+ id,
+ name,
+ selectedId,
+ handleClick,
+}: {
+ id: number;
+ name: string;
+ selectedId: number;
+ handleClick: (id: number, name: string) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/features/vote/candidate-grid.tsx b/src/components/features/vote/candidate-grid.tsx
new file mode 100644
index 0000000..c2c2d73
--- /dev/null
+++ b/src/components/features/vote/candidate-grid.tsx
@@ -0,0 +1,24 @@
+'use client';
+import React from 'react';
+
+import CandidateCard from './candidate-card';
+
+export default function CandidateGrid({
+ list,
+ selectedId,
+ handleClick,
+}: {
+ list: { id: number; name: string }[];
+ selectedId: number;
+ handleClick: (id: number, name: string) => void;
+}) {
+ const columns = list.length <= 5 ? 1 : 2;
+
+ return (
+
+ {list.map(({ id, name }, idx) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/features/vote/go-to-vote-button.tsx b/src/components/features/vote/go-to-vote-button.tsx
new file mode 100644
index 0000000..4ca3d86
--- /dev/null
+++ b/src/components/features/vote/go-to-vote-button.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+type ButtonProps = {
+ text1: string;
+ text2: string;
+ href: string;
+ disabled: boolean;
+};
+
+export default function GoToVoteButton({ text1, text2, href, disabled }: ButtonProps) {
+ const router = useRouter();
+
+ const onResultClick = () => {
+ router.push(href + '/result');
+ };
+
+ return (
+
+
+
+ {text1}
+
+ {text2}
+
+
+
+
+
+
+
+
+ {/* Disabled division */}
+ {disabled && (
+
+ )}
+
+ );
+}
diff --git a/src/components/features/vote/header.tsx b/src/components/features/vote/header.tsx
new file mode 100644
index 0000000..a043c94
--- /dev/null
+++ b/src/components/features/vote/header.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import React from 'react';
+import { usePathname } from 'next/navigation';
+
+export default function Header() {
+ const pathname = usePathname();
+ const segments = pathname.split('/');
+
+ const current = segments[3];
+ const sub = segments[4];
+
+ const activeIndex = (() => {
+ if (current === 'list') return 0;
+ if (['front', 'back', 'team', 'aggregate', 'loading'].includes(current) && sub !== 'result') return 1;
+ if (sub === 'result') return 2;
+ return -1;
+ })();
+
+ if (activeIndex === -1) return null;
+
+ const totalSteps = 3;
+ const sepLen = 5;
+
+ return (
+
+ {Array.from({ length: totalSteps }).map((_, stepIdx) => (
+
+
+ {stepIdx < totalSteps - 1 &&
+ Array.from({ length: sepLen }).map((_, i) => (
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/src/components/features/vote/result-card.tsx b/src/components/features/vote/result-card.tsx
new file mode 100644
index 0000000..d2ff8e7
--- /dev/null
+++ b/src/components/features/vote/result-card.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import Crown from '@/public/icons/crown.svg';
+
+export default function ResultCard({ name, votes, isTop }: { name: string; votes: number; isTop: boolean }) {
+ return (
+
+ {name}
+ {/* 최고 특표자인 경우 왕관 표시*/}
+ {isTop &&
}
+
+ {/* 반투명 레이어: 최고 득표자가 아닌 경우에만 */}
+ {!isTop &&
}
+
+ {/* 득표 수 표시 */}
+
+ {votes}
+
+
+ );
+}
diff --git a/src/components/features/vote/result-grid.tsx b/src/components/features/vote/result-grid.tsx
new file mode 100644
index 0000000..5826187
--- /dev/null
+++ b/src/components/features/vote/result-grid.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import ResultCard from './result-card';
+
+type ResultItem = {
+ id: number;
+ name: string;
+ voteCount: number;
+};
+
+export default function ResultGrid({ list }: { list: ResultItem[] }) {
+ const columns = list.length <= 5 ? 1 : 2;
+
+ // 최고 득표수 계산
+ const maxVotes = Math.max(...list.map((item) => item.voteCount));
+
+ return (
+
+ {list.map((item) => (
+ 0}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/features/vote/select-vote-button.tsx b/src/components/features/vote/select-vote-button.tsx
new file mode 100644
index 0000000..1be198b
--- /dev/null
+++ b/src/components/features/vote/select-vote-button.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+type ButtonProps = {
+ text: string;
+ href: string;
+};
+
+export default function SelectVoteButton({ text, href }: ButtonProps) {
+ const router = useRouter();
+
+ const handleClick = () => {
+ router.push(href); // 페이지 이동
+ };
+
+ return (
+
+ );
+}
diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/lib/config/.gitkeep b/src/lib/config/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/lib/constants/vote-types.ts b/src/lib/constants/vote-types.ts
new file mode 100644
index 0000000..e86b33b
--- /dev/null
+++ b/src/lib/constants/vote-types.ts
@@ -0,0 +1 @@
+export const VOTE_TYPES = ['BE_LEADER', 'FE_LEADER', 'DEMO_DAY'] as const;
diff --git a/src/lib/store/use-token-store.ts b/src/lib/store/use-token-store.ts
new file mode 100644
index 0000000..d971f22
--- /dev/null
+++ b/src/lib/store/use-token-store.ts
@@ -0,0 +1,19 @@
+import { create } from 'zustand';
+
+interface TokenState {
+ accessToken: string | null;
+
+ setAccessToken: (token: string) => void;
+ clear: () => void;
+}
+
+export const useTokenStore = create((set) => ({
+ accessToken: null,
+
+ setAccessToken: (token) => set({ accessToken: token }),
+
+ clear: () =>
+ set({
+ accessToken: null,
+ }),
+}));
diff --git a/src/lib/utils/.gitkeep b/src/lib/utils/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..7c6f6ac
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,36 @@
+// middleware.ts
+import { NextRequest, NextResponse } from 'next/server';
+
+const PUBLIC_ONLY_PATHS = ['/login', '/sign-up'];
+const PROTECTED_EXACT_MATCHES: RegExp[] = [/^\/vote\/[^/]+\/[^/]+\/aggregate$/, /^\/vote\/[^/]+\/(?!list$)[^/]+$/];
+
+/**
+ * Edge 런타임에서는 http 외부 fetch가 막히므로
+ * refreshToken 쿠키 존재만으로 로그인 여부를 판단.
+ */
+function isLoggedIn(req: NextRequest): boolean {
+ return Boolean(req.cookies.get('refreshToken')?.value);
+}
+
+function matchesProtectedPath(pathname: string): boolean {
+ return PROTECTED_EXACT_MATCHES.some((regex) => regex.test(pathname));
+}
+
+export function middleware(req: NextRequest) {
+ const { pathname } = req.nextUrl;
+ const loggedIn = isLoggedIn(req);
+
+ if (matchesProtectedPath(pathname) && !loggedIn) {
+ return NextResponse.redirect(new URL('/vote/list', req.url));
+ }
+
+ if (PUBLIC_ONLY_PATHS.includes(pathname) && loggedIn) {
+ return NextResponse.redirect(new URL('/', req.url));
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/login', '/sign-up', '/vote/:voteType/:castType', '/vote/:voteType/:castType/aggregate'],
+};
diff --git a/src/services/api/auth.ts b/src/services/api/auth.ts
new file mode 100644
index 0000000..2cc1679
--- /dev/null
+++ b/src/services/api/auth.ts
@@ -0,0 +1,45 @@
+import Cookies from 'js-cookie';
+
+import { useTokenStore } from '@/lib/store/use-token-store';
+import { LoginInput, SignUpRequest } from '@/types/auth.dto';
+import type { User } from '@/types/user';
+
+import { axiosInstance } from './axios';
+
+export const login = async (input: LoginInput): Promise => {
+ const res = await axiosInstance.post('/api/auth/login', input);
+
+ if (res.status === 200) {
+ const { accessToken, refreshToken } = res.data.data;
+ useTokenStore.getState().setAccessToken(accessToken);
+
+ const isSecure = typeof window !== 'undefined' && window.location.protocol === 'https:';
+ Cookies.set('refreshToken', refreshToken, {
+ path: '/',
+ secure: isSecure,
+ sameSite: isSecure ? 'None' : 'Lax',
+ });
+ }
+};
+
+export const signup = async (input: SignUpRequest): Promise => {
+ await axiosInstance.post('/api/auth/signup', input);
+};
+
+export const logout = async (): Promise => {
+ await axiosInstance.post('/api/auth/logout');
+
+ useTokenStore.getState().clear();
+ Cookies.remove('refreshToken', { path: '/' });
+};
+
+export const me = async (): Promise => {
+ try {
+ const res = await axiosInstance.get('/api/auth/me');
+ const myData = res.data.data;
+ return myData;
+ } catch (error) {
+ console.log(error);
+ return null;
+ }
+};
diff --git a/src/services/api/axios.ts b/src/services/api/axios.ts
new file mode 100644
index 0000000..5f92aba
--- /dev/null
+++ b/src/services/api/axios.ts
@@ -0,0 +1,88 @@
+import axios from 'axios';
+import Cookies from 'js-cookie';
+
+import { useTokenStore } from '@/lib/store/use-token-store';
+
+export const axiosInstance = axios.create({
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ withCredentials: true,
+});
+
+// accessToken auto-injection
+axiosInstance.interceptors.request.use(
+ async (config) => {
+ const token = useTokenStore.getState().accessToken;
+
+ if (!token) {
+ const refreshToken = Cookies.get('refreshToken');
+
+ // /api/auth/refresh
+ if (refreshToken) {
+ try {
+ const { data } = await axios.post(
+ '/api/auth/refresh',
+ {},
+ {
+ headers: { 'Refresh-Token': refreshToken },
+ },
+ );
+ useTokenStore.getState().setAccessToken(data.data.accessToken);
+ } catch (refreshError) {
+ useTokenStore.getState().clear();
+ Cookies.remove('refreshToken', { path: '/' });
+ return Promise.reject(refreshError);
+ }
+ }
+ }
+
+ const newToken = useTokenStore.getState().accessToken;
+
+ if (newToken) {
+ config.headers.Authorization = `Bearer ${newToken}`;
+ }
+
+ return config;
+ },
+ (err) => Promise.reject(err),
+);
+
+// accessToken auto-refresh
+axiosInstance.interceptors.response.use(
+ (res) => res,
+ async (err) => {
+ const original = err.config;
+
+ // status 401: Unauthorized
+ if (err.response?.status === 401 && !original._retry) {
+ original._retry = true;
+
+ const refreshToken = Cookies.get('refreshToken');
+ if (!refreshToken) {
+ useTokenStore.getState().clear();
+ Cookies.remove('refreshToken', { path: '/' });
+ return Promise.reject(err);
+ }
+
+ // /api/auth/refresh
+ try {
+ const { data } = await axios.post(
+ '/api/auth/refresh',
+ {},
+ {
+ headers: { 'Refresh-Token': refreshToken },
+ },
+ );
+ useTokenStore.getState().setAccessToken(data.data.accessToken);
+ original.headers.Authorization = `Bearer ${data.data.accessToken}`;
+ return axiosInstance(original);
+ } catch (refreshError) {
+ useTokenStore.getState().clear();
+ return Promise.reject(refreshError);
+ }
+ }
+
+ return Promise.reject(err);
+ },
+);
diff --git a/src/services/api/vote.ts b/src/services/api/vote.ts
new file mode 100644
index 0000000..f6c7fe3
--- /dev/null
+++ b/src/services/api/vote.ts
@@ -0,0 +1,44 @@
+import { AxiosError } from 'axios';
+
+import { VOTE_TYPES } from '@/lib/constants/vote-types';
+
+import { axiosInstance } from './axios';
+
+export const getVoteList = async (voteType: (typeof VOTE_TYPES)[number]): Promise<{ id: number; name: string }[]> => {
+ try {
+ const res = await axiosInstance.get(`/api/vote/${voteType}`);
+ const voteList = res.data.data;
+ return voteList;
+ } catch (error) {
+ console.log(error);
+ return [];
+ }
+};
+
+export const vote = async (voteType: (typeof VOTE_TYPES)[number], candidateId: number): Promise => {
+ try {
+ const res = await axiosInstance.post(`/api/vote/${voteType}`, { candidateId });
+ return res.data.data.message;
+ } catch (error) {
+ const err = error as AxiosError<{ code: string; reason: string }>;
+ if (err.response?.status === 403 && err.response.data?.code === 'Member_403') {
+ alert(err.response.data.reason);
+ } else {
+ console.error(error);
+ }
+ return null;
+ }
+};
+
+export const getVoteResult = async (
+ voteType: (typeof VOTE_TYPES)[number],
+): Promise<{ id: number; name: string; voteCount: number }[]> => {
+ try {
+ const res = await axiosInstance.get(`/api/vote/${voteType}/result`);
+ const voteResult = res.data.data;
+ return voteResult;
+ } catch (error) {
+ console.log(error);
+ return [];
+ }
+};
diff --git a/src/services/query/client.tsx b/src/services/query/client.tsx
new file mode 100644
index 0000000..19cff21
--- /dev/null
+++ b/src/services/query/client.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import React, { useState } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+
+import { createQueryClient } from './server';
+
+let browserQueryClient: QueryClient | undefined = undefined;
+
+export function getQueryClient() {
+ if (!browserQueryClient) {
+ browserQueryClient = createQueryClient();
+ }
+ return browserQueryClient;
+}
+
+export default function QueryProvider({ children }: { children: React.ReactNode }) {
+ const [queryClient] = useState(() => getQueryClient());
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/src/services/query/server.ts b/src/services/query/server.ts
new file mode 100644
index 0000000..942aea1
--- /dev/null
+++ b/src/services/query/server.ts
@@ -0,0 +1,11 @@
+import { QueryClient } from '@tanstack/react-query';
+
+export function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60, // 1 minute
+ },
+ },
+ });
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..c205d2c
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,90 @@
+@import 'tailwindcss';
+
+@theme {
+ --font-pretendard: var(--font-pretendard);
+
+ /* Use line-height: normal; and letter-spacing: normal; */
+ /* Headline Typography System */
+ --text-headline-01: 38px;
+ --text-headline-01--font-weight: 700;
+
+ --text-headline-02: 28px;
+ --text-headline-02--font-weight: 700;
+
+ --text-headline-03: 26px;
+ --text-headline-03--font-weight: 700;
+
+ --text-headline-04: 20px;
+ --text-headline-04--font-weight: 700;
+
+ /* Body Typography System */
+ --text-body-01: 19px;
+ --text-body-01--font-weight: 700;
+
+ --text-body-02: 16px;
+ --text-body-02--font-weight: 700;
+
+ --text-body-03: 10px;
+ --text-body-03--font-weight: 700;
+
+ /* Caption Typography System */
+ --text-caption-01: 18px;
+ --text-caption-01--font-weight: 700;
+
+ --text-caption-02: 18px;
+ --text-caption-02--font-weight: 400;
+
+ --text-caption-03: 14px;
+ --text-caption-03--font-weight: 700;
+
+ --text-caption-04: 14px;
+ --text-caption-04--font-weight: 400;
+
+ /* Grayscale Color System */
+ --color-grayscale-00-black: #000000;
+ --color-grayscale-00-black-tp: rgba(0, 0, 0, 0.4);
+ --color-grayscale-01: #979797;
+ --color-grayscale-02: #9b9b9b;
+ --color-grayscale-03: #d9d9d9;
+ --color-grayscale-04: #f5f5f5;
+ --color-grayscale-05-white: #ffffff;
+
+ /* Neutral Color System with Opacity */
+ --color-neutral-01: #eeefe9;
+ --color-neutral-01-tp: rgba(238, 239, 233, 0.7);
+ --color-neutral-02: #e1e1d7;
+ --color-neutral-02-tp: rgba(225, 225, 215, 0.33);
+
+ /* Accent Color System with Opaticy */
+ --color-accent-brown: #6b6758;
+ --color-accent-dark-tp: rgba(63, 55, 26, 0.5);
+}
+
+@layer base {
+ body {
+ background: var(--color-grayscale-05-white);
+ color: var(--color-grayscale-00-black);
+ }
+}
+
+@layer components {
+ .mobile-frame {
+ width: 100vw;
+ height: 100vh;
+ max-width: 338px;
+ max-height: 630px;
+ border-radius: 38px;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+ }
+}
+
+@layer utilities {
+ .hide-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+ .hide-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/src/types/auth.dto.ts b/src/types/auth.dto.ts
new file mode 100644
index 0000000..8c3009e
--- /dev/null
+++ b/src/types/auth.dto.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+export const PART_LABELS = ['BACKEND', 'FRONTEND'] as const;
+
+export const TEAM_LABELS = ['PROMESA', 'LOOPZ', 'INFLUY', 'HANIHOME', 'DEARDREAM'] as const;
+
+export const loginSchema = z.object({
+ identifier: z.string().min(1, '아이디를 입력하세요.'),
+ password: z.string().min(1, '비밀번호를 입력하세요.'),
+});
+
+export const signUpSchema = loginSchema
+ .extend({
+ name: z.string().min(1, '이름을 입력하세요.'),
+ confirmPassword: z.string(),
+ email: z.string().email('유효한 이메일을 입력하세요.'),
+ part: z.enum(PART_LABELS, {
+ errorMap: () => ({ message: '파트를 선택하세요.' }),
+ }),
+ team: z.enum(TEAM_LABELS, {
+ errorMap: () => ({ message: '팀을 선택하세요.' }),
+ }),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: '비밀번호가 일치하지 않습니다.',
+ path: ['confirmPassword'],
+ });
+
+export type LoginInput = z.infer;
+export type SignUpInput = z.infer;
+export type SignUpRequest = Omit;
diff --git a/src/types/user.d.ts b/src/types/user.d.ts
new file mode 100644
index 0000000..72301ab
--- /dev/null
+++ b/src/types/user.d.ts
@@ -0,0 +1,6 @@
+export interface User {
+ identifier: string;
+ part: string;
+ team: string;
+ name: string;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..318aaf6
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "baseUrl": ".",
+ "paths": {
+ "@/public/*": ["public/*"],
+ "@/app/*": ["src/app/*"],
+ "@/components/*": ["src/components/*"],
+ "@/hooks/*": ["src/hooks/*"],
+ "@/lib/*": ["src/lib/*"],
+ "@/services/*": ["src/services/*"],
+ "@/styles/*": ["src/styles/*"],
+ "@/types/*": ["src/types/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}