diff --git a/.github/ISSUE_TEMPLATE/issue-form.yml b/.github/ISSUE_TEMPLATE/issue-form.yml new file mode 100644 index 0000000..fb44c8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-form.yml @@ -0,0 +1,60 @@ +name: 'FE 이슈 생성' +description: 'FE Repo에 이슈를 생성하며, 생성된 이슈는 Jira와 연동됩니다.' +labels: [order] +title: '이슈 이름을 작성해주세요' +body: + - type: input + id: parentKey + attributes: + label: '🎫Epic Ticket Number' + description: 'Epic의 Ticket Number를 기입해주세요' + placeholder: 'GE-00' + validations: + required: true + + - type: textarea + id: description + attributes: + label: '📋이슈 내용(Description)' + description: '이슈에 대해서 자세히 설명해주세요' + validations: + required: true + + - type: textarea + id: tasks + attributes: + label: '☑️체크리스트(Tasks)' + description: '해당 이슈에 대해 필요한 작업목록을 작성해주세요' + value: | + - [ ] Task1 + - [ ] Task2 + validations: + required: true + + - type: input + id: taskType + attributes: + label: '🗃️업무 유형' + description: 'Docs/Feature/Fix/Hotfix 중 하나를 작성해주세요' + placeholder: 'Docs/Feature/Fix/Hotfix' + validations: + required: true + + - type: input + id: branch + attributes: + label: '🌳브랜치 이름(Branch Name)' + description: '해당 issue로 생성될 branch 이름을 소문자로 기입해주세요' + placeholder: '[type]/[branch-name]' + validations: + required: true + + - type: textarea + id: references + attributes: + label: '📁참조(References)' + description: '해당 이슈과 관련된 레퍼런스를 참조해주세요' + value: | + - Reference1 + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c8b8b71 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## #️⃣연관된 이슈 + +> #이슈번호 (깃허브 이슈 번호를 작성해주세요) + +## 📝작업 내용 + +> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +## 📷스크린샷 (선택) + +> 변경사항을 첨부해주세요 + +## 💬리뷰 요구사항(선택) + +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 +> +> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? diff --git a/.github/workflows/create-jira-issue.yml b/.github/workflows/create-jira-issue.yml new file mode 100644 index 0000000..b479e68 --- /dev/null +++ b/.github/workflows/create-jira-issue.yml @@ -0,0 +1,86 @@ +name: Create Jira issue +on: + issues: + types: + - opened +permissions: + contents: write + issues: write +jobs: + create-issue: + name: Create Jira issue + runs-on: ubuntu-latest + steps: + - name: Login + uses: atlassian/gajira-login@v3 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + + - name: Checkout main code + uses: actions/checkout@v4 + with: + ref: develop + + - name: Issue Parser + uses: stefanbuck/github-issue-praser@v3 + id: issue-parser + with: + template-path: .github/ISSUE_TEMPLATE/issue-form.yml + + - name: Log Issue Parser + run: | + echo '${{ steps.issue-parser.outputs.jsonString }}' + + - name: Convert markdown to Jira Syntax + uses: peter-evans/jira2md@v1 + id: md2jira + with: + input-text: | + ### Github Issue Link + - ${{ github.event.issue.html_url }} + + ${{ github.event.issue.body }} + mode: md2jira + + - name: Create Issue + id: create + uses: atlassian/gajira-create@v3 + with: + project: GE + issuetype: "${{ steps.issue-parser.outputs.issueparser_taskType }}" + summary: "${{ github.event.issue.title }}" + description: "${{ steps.md2jira.outputs.output-text }}" + fields: | + { + "parent": { "key": "${{ steps.issue-parser.outputs.issueparser_parentKey }}" }, + "labels": ${{ toJson(github.event.issue.labels.*.name) }} + } + + - name: Log created issue + run: echo "Jira Issue ${{ steps.issue-parser.outputs.parentKey }}/${{ steps.create.outputs.issue }} was created" + + - name: Checkout develop code + uses: actions/checkout@v4 + with: + ref: develop + + - name: Make final branch name + id: make-branch + run: | + INPUT="${{ steps.issue-parser.outputs.issueparser_branch }}" + TICKET="${{ steps.create.outputs.issue }}" + FINAL_BRANCH=$(echo "$INPUT" | sed "s/\//\/$TICKET-/") + echo "final_branch=$FINAL_BRANCH" >> $GITHUB_OUTPUT + + - name: Create branch with Ticket number + run: | + git checkout -b ${{ steps.make-branch.outputs.final_branch }} + git push origin ${{ steps.make-branch.outputs.final_branch }} + + - name: Update issue title + uses: actions-cool/issues-helper@v3 + with: + actions: "update-issue" + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..07eeec6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,30 @@ +name: Deploy + +on: + push: + branches: ['main'] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: creates output + run: sh ./build.sh + + - name: Pushes to another repository + id: push_directory + uses: cpina/github-action-push-to-another-repository@main + env: + API_TOKEN_GITHUB: ${{ secrets.AUTO_ACTIONS }} + with: + source-directory: 'output' + destination-github-username: dragunshin + destination-repository-name: next-vote-22nd + user-email: ${{ secrets.EMAIL }} + commit-message: ${{ github.event.commits[0].message }} + target-branch: main + + - name: Test get variable exported by push-to-another-repository + run: echo $DESTINATION_CLONED_DIRECTORY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09be47a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build outputs +.next/ +out/ +build/ + +# Environment variables +.env +.env.local +.env*.local + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS +.DS_Store +*.swp +*.swo + +# IDE +.vscode/ +.idea/ + +# Vercel +.vercel + +# Output (GitHub Actions) +output/ diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..b497b27 --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# GitHub Actions workspace is already in the repo root +# Copy only next-vote directory contents for Vercel deployment +rm -rf output +mkdir -p output +cp -R ./next-vote/* ./output/ \ No newline at end of file diff --git a/next-vote/.gitignore b/next-vote/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/next-vote/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/next-vote/README.md b/next-vote/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/next-vote/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/next-vote/app/api/proxy/[...path]/route.ts b/next-vote/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..a808a47 --- /dev/null +++ b/next-vote/app/api/proxy/[...path]/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// EC2 백엔드 API URL (서버 사이드에서만 사용) +const EC2_API_URL = process.env.EC2_API_BASE_URL || 'http://ec2-52-79-241-109.ap-northeast-2.compute.amazonaws.com/api'; + +// 모든 HTTP 메서드 처리 +async function handleRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + try { + const resolvedParams = await params; + const path = resolvedParams.path.join('/'); + const url = `${EC2_API_URL}/${path}`; + + // 요청 본문 가져오기 + let body = null; + if (request.method !== 'GET' && request.method !== 'HEAD') { + try { + body = await request.text(); + } catch (e) { + // 본문이 없는 경우 무시 + } + } + + // 헤더 복사 (필요한 헤더만) + const headers: Record = { + 'Content-Type': request.headers.get('Content-Type') || 'application/json', + }; + + // 쿠키가 있으면 전달 + const cookie = request.headers.get('Cookie'); + if (cookie) { + headers['Cookie'] = cookie; + } + + // EC2 API로 요청 + const response = await fetch(url, { + method: request.method, + headers, + body: body || undefined, + credentials: 'include', + }); + + // 응답 본문 + const responseData = await response.text(); + + // 응답 헤더 복사 + const responseHeaders = new Headers(); + responseHeaders.set('Content-Type', response.headers.get('Content-Type') || 'application/json'); + + // Set-Cookie 헤더 전달 (인증 토큰을 위해 중요) + const setCookie = response.headers.get('Set-Cookie'); + if (setCookie) { + responseHeaders.set('Set-Cookie', setCookie); + } + + return new NextResponse(responseData, { + status: response.status, + headers: responseHeaders, + }); + } catch (error) { + console.error('Proxy error:', error); + return NextResponse.json( + { error: 'Proxy request failed', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return handleRequest(request, context); +} + +export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return handleRequest(request, context); +} + +export async function PUT(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return handleRequest(request, context); +} + +export async function PATCH(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return handleRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return handleRequest(request, context); +} diff --git a/next-vote/app/auth/login/page.tsx b/next-vote/app/auth/login/page.tsx new file mode 100644 index 0000000..77da73a --- /dev/null +++ b/next-vote/app/auth/login/page.tsx @@ -0,0 +1,5 @@ +import { LoginForm } from '@/components/auth/login-form'; + +export default function LoginPage() { + return ; +} diff --git a/next-vote/app/auth/signup/page.tsx b/next-vote/app/auth/signup/page.tsx new file mode 100644 index 0000000..93e4507 --- /dev/null +++ b/next-vote/app/auth/signup/page.tsx @@ -0,0 +1,5 @@ +import { SignUpForm } from '@/components/auth/signup-form'; + +export default function SignUpPage() { + return ; +} diff --git a/next-vote/app/candidates/[id]/page.tsx b/next-vote/app/candidates/[id]/page.tsx new file mode 100644 index 0000000..5d2277b --- /dev/null +++ b/next-vote/app/candidates/[id]/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useRouter, useParams } from "next/navigation"; +import Image from "next/image"; +import { getCandidateById } from "@/lib/data/candidates"; + +export default function CandidateDetailPage() { + const router = useRouter(); + const params = useParams(); + const candidateId = params.id as string; + + const candidate = getCandidateById(candidateId); + + if (!candidate) { + return ( +
+

후보자를 찾을 수 없습니다.

+ +
+ ); + } + + return ( +
+
+ +

후보자 정보

+
+ + {/* 후보자들 */} +
+ {/* 후보자 이미지 */} +
+ {candidate.name} { + const target = e.target as HTMLImageElement; + target.src = "/images/candidates/profile.svg"; + }} + /> +
+ + {/* 후보자 기본 정보 */} +
+
+

{candidate.name}

+ + {candidate.team} + +
+

+ {candidate.part === "FRONTEND" ? "Front-End" : "Back-End"} 파트 +

+
+ + {/* 소개 */} +
+

후보자 소개

+
+

+ {candidate.introduction} +

+
+
+ + {/* 투표하기 버튼 */} + +
+
+ ); +} diff --git a/next-vote/app/candidates/page.tsx b/next-vote/app/candidates/page.tsx new file mode 100644 index 0000000..e66d70e --- /dev/null +++ b/next-vote/app/candidates/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { + frontendCandidates, + backendCandidates, + type Candidate, +} from "@/lib/data/candidates"; + +type Part = "FRONTEND" | "BACKEND"; + +export default function CandidatesPage() { + const router = useRouter(); + const [selectedPart, setSelectedPart] = useState("FRONTEND"); + + const candidates = + selectedPart === "FRONTEND" ? frontendCandidates : backendCandidates; + + return ( +
+ {/* Header */} +
+ +

Members

+
+ + {/* Tabs */} +
+ + +
+ + {/* 후보자들 */} +
+
+ {candidates.map((candidate) => ( + router.push(`/candidates/${candidate.id}`)} + /> + ))} +
+
+
+ ); +} + +function CandidateCard({ + candidate, + onClick, +}: { + candidate: Candidate; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/next-vote/app/favicon.ico b/next-vote/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/next-vote/app/favicon.ico differ diff --git a/next-vote/app/globals.css b/next-vote/app/globals.css new file mode 100644 index 0000000..9802ad8 --- /dev/null +++ b/next-vote/app/globals.css @@ -0,0 +1,347 @@ +/* @import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + height: 100vh; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + background-color: #1a1a1a; + font-family: system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root, +main { + width: 375px; + height: 812px; + overflow-y: auto; + overflow-x: hidden; + position: relative; + box-shadow: 0 0 50px rgba(0, 0, 0, 0.5); + background-color: white; + + scrollbar-width: none; + -ms-overflow-style: none; +} + +#root::-webkit-scrollbar, +main::-webkit-scrollbar { + display: none; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: 1.5; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input { + font-family: inherit; +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} */ + +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background-color: #1a1a1a; + font-family: system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +main { + width: 100%; + max-width: 575px; /* 모바일 100% 데스크탑 최대 375px */ + min-height: 100vh; + margin-inline: auto; /* 가운데 정렬 */ + + overflow-y: auto; + overflow-x: hidden; + position: relative; + box-shadow: 0 0 50px rgba(0, 0, 0, 0.5); + background-color: white; + + scrollbar-width: none; + -ms-overflow-style: none; +} + +main::-webkit-scrollbar { + display: none; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: 1.5; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input { + font-family: inherit; +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/next-vote/app/layout.tsx b/next-vote/app/layout.tsx new file mode 100644 index 0000000..ddf159b --- /dev/null +++ b/next-vote/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
{children}
+ + + ); +} diff --git a/next-vote/app/page.tsx b/next-vote/app/page.tsx new file mode 100644 index 0000000..80a6f5d --- /dev/null +++ b/next-vote/app/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useAuthStore } from "@/stores/useAuthStore"; +import { authService } from "@/services/auth.service"; + +type VoteCategory = "frontend" | "backend" | "demo"; + +const voteCategories = [ + { id: "frontend" as VoteCategory, label: "프론트엔드 파트장 투표" }, + { id: "backend" as VoteCategory, label: "백엔드 파트장 투표" }, + { id: "demo" as VoteCategory, label: "데모데이 투표" }, +]; + +export default function Home() { + const router = useRouter(); + const [showMenu, setShowMenu] = useState(false); + const [totalVotes] = useState(0); + + // Zustand에서 로그인 상태와 사용자 정보 가져오기 + const { isAuthenticated, user, initializeAuth, logout } = useAuthStore(); + + // 페이지 로드 시 localStorage에서 사용자 정보 복원 + useEffect(() => { + initializeAuth(); + }, [initializeAuth]); + + // 로그아웃 핸들러 + const handleLogout = async () => { + try { + await authService.logout(); + logout(); + setShowMenu(false); + router.push("/"); + } catch (error) { + console.error("로그아웃 실패:", error); + // 실패해도 로컬 상태는 정리 + logout(); + setShowMenu(false); + } + }; + + // const handleVoteClick = () => { + // if (!isAuthenticated) { + // router.push("/auth/login"); + // } else { + // router.push("/vote/front"); + // } + // }; + + const handleVoteClick2 = () => { + if (!isAuthenticated) { + router.push("/auth/login"); + } else { + router.push("/vote/front"); + } + }; + const handleVoteClick3 = () => { + if (!isAuthenticated) { + router.push("/auth/login"); + } else { + router.push("/vote/team"); + } + }; + + return ( +
+
+
+ {isAuthenticated && user && {user.username}님 환영합니다} +
+ + + {showMenu && ( +
+ setShowMenu(false)} + > + 후보자 보기 + + {isAuthenticated ? ( + + ) : ( + <> + setShowMenu(false)} + > + 로그인 + + setShowMenu(false)} + > + 회원가입 + + + )} +
+ )} +
+ +
+
+

+ 🏆 2025 CEOS +

+

22ND AWARDS

+
+ +
+
+ {voteCategories.map((category) => ( +
+ # {category.label} +
+ ))} +
+
+ +
+ {/* +

+ 현재 총 {totalVotes}건의 투표가 + 진행되었어요! +

*/} + + + + +
+
+
+ ); +} diff --git a/next-vote/app/vote/front/page.tsx b/next-vote/app/vote/front/page.tsx new file mode 100644 index 0000000..edcc65f --- /dev/null +++ b/next-vote/app/vote/front/page.tsx @@ -0,0 +1,191 @@ +// "use client"; +// import { useRouter } from "next/navigation"; +// import Image from "next/image"; + +// import { +// Field, +// FieldContent, +// FieldDescription, +// FieldGroup, +// FieldLabel, +// FieldSet, +// FieldTitle, +// } from "@/components/ui/field"; +// import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +// export default function FieldChoiceCard() { +// const router = useRouter(); +// return ( +// <> +// {" "} +//
+// +//

투표하기

+//
+//
+// +//
+// +// 프론트 파트장 투표 +// + +// +// +// +// +// 신용섭 +// +// +// +// +// +// +// +// 최무헌 +// +// +// +// +// +// +// +// Menual +// 남성 그루밍 플랫폼 +// +// +// +// +// +//
+//
+ +// +//
+// +// ); +// } + +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import axios from "axios"; + +import { + Field, + FieldContent, + FieldGroup, + FieldLabel, + FieldSet, + FieldTitle, +} from "@/components/ui/field"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +import { frontendCandidates } from "@/lib/data/candidates"; + +export default function FieldChoiceCard() { + const router = useRouter(); + + const defaultId = useMemo(() => frontendCandidates[0]?.id ?? "", []); + const [selectedId, setSelectedId] = useState(defaultId); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onVote = async () => { + if (!selectedId || isSubmitting) return; + + try { + setIsSubmitting(true); + + // 추후 수정 + await axios.post("/api/v1/votes", { + part: "FRONTEND", + candidateId: selectedId, + }); + //router.push('/vote/resultFront') + } catch (e) { + console.error(e); + alert("투표에 실패했어요 잠시 후 다시 시도해주세요"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> +
+ +

투표하기

+
+ +
+ +
+ + 프론트 파트장 투표 + + + + {frontendCandidates.map((c) => ( + + + + {c.name} + + + + + ))} + +
+
+ +
+ +
+
+ + ); +} diff --git a/next-vote/app/vote/resultFront/page.tsx b/next-vote/app/vote/resultFront/page.tsx new file mode 100644 index 0000000..5aba890 --- /dev/null +++ b/next-vote/app/vote/resultFront/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import axios from "axios"; + +import { + Field, + FieldContent, + FieldGroup, + FieldLabel, + FieldSet, + FieldTitle, +} from "@/components/ui/field"; + +import { frontendCandidates } from "@/lib/data/candidates"; + +type VoteResultItem = { + candidateId: string; + votes: number; +}; + +export default function CandidateVoteResult() { + const router = useRouter(); + + const [voteMap, setVoteMap] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchResults = async () => { + try { + setIsLoading(true); + + const res = await axios.get<{ results: VoteResultItem[] }>( + "/api/v1/votes/results", + { params: { part: "FRONTEND" } } + ); + + const next: Record = {}; + for (const item of res.data.results ?? []) { + next[item.candidateId] = item.votes; + } + setVoteMap(next); + } catch (e) { + console.error(e); + alert("결과를 불러오지 못했어요"); + } finally { + setIsLoading(false); + } + }; + + fetchResults(); + }, []); + + // 득표수 내림차순 (동점이면 이름 오름차순) + const sortedCandidates = useMemo(() => { + const list = frontendCandidates.map((c) => ({ + ...c, + votes: voteMap[c.id] ?? 0, + })); + + return list.sort((a, b) => { + if (b.votes !== a.votes) return b.votes - a.votes; + return a.name.localeCompare(b.name, "ko"); + }); + }, [voteMap]); + + return ( + <> +
+ +

투표 결과

+
+ +
+ +
+ + 프론트 파트장 투표 결과 + + +
+ {sortedCandidates.map((c) => ( +
+ + + +
+ {c.name} + + {isLoading ? "-" : `${c.votes}표`} + +
+
+
+
+
+ ))} +
+
+
+ +
+ +
+
+ + ); +} diff --git a/next-vote/app/vote/resultTeam/page.tsx b/next-vote/app/vote/resultTeam/page.tsx new file mode 100644 index 0000000..503d555 --- /dev/null +++ b/next-vote/app/vote/resultTeam/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import axios from "axios"; + +import { + Field, + FieldContent, + FieldGroup, + FieldLabel, + FieldSet, + FieldTitle, +} from "@/components/ui/field"; + +import { partsData } from "@/lib/data/teams"; + +type VoteResultItem = { + teamId?: string; + candidateId?: string; + votes: number; +}; + +export default function TeamVoteResult() { + const router = useRouter(); + + const [voteMap, setVoteMap] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + + // mockData라서 구성이 이상할 수 있음 + const frontendTeams = useMemo(() => { + return partsData.find((p) => p.id === "frontend")?.teams ?? []; + }, []); + + useEffect(() => { + const fetchResults = async () => { + try { + setIsLoading(true); + + const res = await axios.get<{ results: VoteResultItem[] }>( + "/api/v1/votes/results", + { params: { part: "FRONTEND" } } + ); + + const next: Record = {}; + for (const item of res.data.results ?? []) { + const id = item.teamId ?? item.candidateId; + if (!id) continue; + next[id] = item.votes; + } + setVoteMap(next); + } catch (e) { + console.error(e); + alert("결과를 불러오지 못했어요"); + } finally { + setIsLoading(false); + } + }; + + fetchResults(); + }, []); + + const sortedTeams = useMemo(() => { + const list = frontendTeams.map((t) => ({ + ...t, + votes: voteMap[t.id] ?? 0, + })); + + return list.sort((a, b) => { + if (b.votes !== a.votes) return b.votes - a.votes; + return a.name.localeCompare(b.name, "en"); + }); + }, [frontendTeams, voteMap]); + + return ( + <> +
+ +

투표 결과

+
+ +
+ +
+ + 데모데이 투표 결과 + + +
+ {sortedTeams.map((t) => ( +
+ + + +
+ {t.name.toUpperCase()} + + {isLoading ? "-" : `${t.votes}표`} + +
+
+
+
+
+ ))} +
+
+
+ +
+ +
+
+ + ); +} diff --git a/next-vote/app/vote/team/page.tsx b/next-vote/app/vote/team/page.tsx new file mode 100644 index 0000000..68d5062 --- /dev/null +++ b/next-vote/app/vote/team/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import axios from "axios"; + +import { + Field, + FieldContent, + FieldGroup, + FieldLabel, + FieldSet, + FieldTitle, +} from "@/components/ui/field"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +import { partsData } from "@/lib/data/teams"; + +export default function FieldChoiceCard() { + const router = useRouter(); + + const frontendTeams = useMemo(() => { + return partsData.find((p) => p.id === "frontend")?.teams ?? []; + }, []); + + const [selectedId, setSelectedId] = useState( + () => frontendTeams[0]?.id ?? "" + ); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onVote = async () => { + if (!selectedId || isSubmitting) return; + + try { + setIsSubmitting(true); + + await axios.post("/api/v1/votes", { + part: "FRONTEND", + candidateId: selectedId, + }); + } catch (e) { + console.error(e); + alert("투표에 실패했어요 잠시 후 다시 시도해주세요"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> +
+ +

투표하기

+
+ +
+ +
+ + 데모데이 투표 + + + + {frontendTeams.map((team) => ( + + + + {team.name.toUpperCase()} + + + + + ))} + +
+
+ +
+ +
+
+ + ); +} diff --git a/next-vote/components.json b/next-vote/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/next-vote/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/next-vote/components/auth/index.ts b/next-vote/components/auth/index.ts new file mode 100644 index 0000000..141e908 --- /dev/null +++ b/next-vote/components/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginForm } from './login-form'; +export { SignUpForm } from './signup-form'; diff --git a/next-vote/components/auth/login-form.tsx b/next-vote/components/auth/login-form.tsx new file mode 100644 index 0000000..6f5e300 --- /dev/null +++ b/next-vote/components/auth/login-form.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import Link from "next/link"; +import { authService } from "@/services/auth.service"; +import { getErrorMessage } from "@/lib/api/error-handler"; +import { loginSchema } from "@/lib/schemas/auth.schema"; +import { useAuthStore } from "@/stores/useAuthStore"; + +// type UserType = "login" | "expert"; + +export function LoginForm() { + const router = useRouter(); + const login = useAuthStore((state) => state.login); + // const [userType, setUserType] = useState("login"); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + // 입력 시 해당 필드의 에러 메시지 초기화 + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + // 일반 에러도 초기화 + if (errors.general) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.general; + return newErrors; + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + setIsLoading(true); + + try { + // 1차 검증: Zod 스키마로 클라이언트 측 검증 + const validatedData = loginSchema.parse({ + email: formData.email, + password: formData.password, + }); + + // 검증 통과 후 API 호출 + const response = await authService.login(validatedData); + + // 로그인 성공 + if (response.statusCode === 0) { + const { userId, username, email, team, part } = response.data; + + // 로그인 정보 저장 + login({ userId, username, email, team, part }); + + // 홈 페이지로 이동 + router.push("/"); + } + } catch (err) { + // Zod 검증 에러 처리 + if (err && typeof err === "object" && "issues" in err) { + const zodError = err as { issues: Array<{ path: string[]; message: string }> }; + const fieldErrors: Record = {}; + + zodError.issues.forEach((issue) => { + const fieldName = issue.path[0] as string; + if (!fieldErrors[fieldName]) { + fieldErrors[fieldName] = issue.message; + } + }); + + setErrors(fieldErrors); + } else { + // API 에러 처리: 백엔드에서 전송한 에러 메시지 (보안상 통합 메시지) + const errorMessage = getErrorMessage(err); + setErrors({ general: errorMessage }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Header */} +
+ +

로그인

+
+ + {/* Tabs */} + {/*
+ + +
*/} + + {/* Form */} +
+
+ {/* Email Input */} +
+ + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Password Input */} +
+ + {errors.password && ( +

{errors.password}

+ )} +
+ + {/* General Error Message */} + {errors.general && ( +
+ {errors.general} +
+ )} + + {/* Login Button */} + + + {/* Sign Up Link */} +
+ + 이메일로 회원가입 + +
+
+
+
+ ); +} diff --git a/next-vote/components/auth/signup-form.tsx b/next-vote/components/auth/signup-form.tsx new file mode 100644 index 0000000..cd62f3b --- /dev/null +++ b/next-vote/components/auth/signup-form.tsx @@ -0,0 +1,332 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { authService } from '@/services/auth.service'; +import { getErrorMessage } from '@/lib/api/error-handler'; +import { partsData, type Part } from '@/lib/data/teams'; +import { z } from 'zod'; + +// Zod 스키마 정의 (회원가입용 - 프로젝트 특화) +const signupSchema = z.object({ + nickname: z.string() + .min(1, '닉네임을 입력해주세요.') + .min(2, '닉네임은 최소 2자 이상이어야 합니다.') + .max(8, '닉네임은 최대 8자까지 가능합니다.'), + email: z.string() + .min(1, '이메일을 입력해주세요.') + .email('올바른 이메일 형식을 입력해주세요.'), + password: z.string() + .min(1, '비밀번호를 입력해주세요.') + .min(8, '비밀번호는 최소 8자 이상이어야 합니다.') + .max(14, '비밀번호는 최대 14자까지 가능합니다.') + .regex(/^(?=.*[a-zA-Z])(?=.*\d)[A-Za-z\d]+$/, '비밀번호는 영문과 숫자 조합이어야 합니다.'), + passwordConfirm: z.string().min(1, '비밀번호 확인을 입력해주세요.'), +}).refine((data) => data.password === data.passwordConfirm, { + message: '비밀번호가 일치하지 않습니다.', + path: ['passwordConfirm'], +}); + +export function SignUpForm() { + const router = useRouter(); + const [selectedPart, setSelectedPart] = useState(null); + const [selectedTeamId, setSelectedTeamId] = useState(''); + const [selectedMemberId, setSelectedMemberId] = useState(''); + const [formData, setFormData] = useState({ + nickname: '', + email: '', + password: '', + passwordConfirm: '', + }); + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + + const selectedPartData = partsData.find((p) => p.id === selectedPart); + const selectedTeam = selectedPartData?.teams.find((t) => t.id === selectedTeamId); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + // 입력 시 해당 필드의 에러 메시지 초기화 + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + // 일반 에러도 초기화 + if (errors.general) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.general; + return newErrors; + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + + // 팀/멤버 선택 확인 + if (!selectedPart || !selectedTeamId || !selectedMemberId) { + setErrors({ general: '파트, 팀, 이름을 모두 선택해주세요.' }); + return; + } + + setIsLoading(true); + + try { + // 1차 검증: Zod 스키마로 클라이언트 측 검증 + const validatedData = signupSchema.parse(formData); + + // 선택한 팀 정보 가져오기 + const selectedTeamData = selectedPartData?.teams.find((t) => t.id === selectedTeamId); + + // 검증 통과 후 API 호출 - 백엔드 요구 형식에 맞춤 + const requestData = { + username: validatedData.nickname, + email: validatedData.email, + password: validatedData.password, + passwordConfirm: validatedData.passwordConfirm, + team: selectedTeamData?.name || '', + part: selectedPart === 'frontend' ? 'FRONTEND' : 'BACKEND', + }; + + console.log('회원가입 요청 데이터:', requestData); + const response = await authService.signup(requestData); + + // 회원가입 성공 + if (response.statusCode === 0) { + console.log('회원가입 성공:', response.data); + // 홈화면으로 이동 + router.push('/'); + } + } catch (err) { + // Zod 검증 에러 처리 + if (err && typeof err === 'object' && 'issues' in err) { + const zodError = err as { issues: Array<{ path: string[]; message: string }> }; + const fieldErrors: Record = {}; + + zodError.issues.forEach((issue) => { + const fieldName = issue.path[0] as string; + if (!fieldErrors[fieldName]) { + fieldErrors[fieldName] = issue.message; + } + }); + + setErrors(fieldErrors); + } else { + // API 에러 처리: 백엔드에서 전송한 에러 메시지 + const errorMessage = getErrorMessage(err); + setErrors({ general: errorMessage }); + } + } finally { + setIsLoading(false); + } + }; + + const isFormValid = + formData.nickname && + formData.email && + formData.password && + formData.passwordConfirm && + selectedPart && + selectedTeamId && + selectedMemberId; + + return ( +
+ {/* Header */} +
+ +

회원가입

+
+ + {/* Tabs */} +
+ + +
+ + {/* Form - 스크롤 가능 영역 */} +
+
+ {/* 팀명 선택 */} +
+ +
+ +
+ + + +
+
+
+ + {/* 이름 선택 */} +
+ +
+ +
+ + + +
+
+
+ + {/* 닉네임 */} +
+ + + {errors.nickname && ( +

{errors.nickname}

+ )} +
+ + {/* 이메일 */} +
+ + + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* 비밀번호 */} +
+ + + {errors.password && ( +

{errors.password}

+ )} +
+ + {/* 비밀번호 재확인 */} +
+ + + {errors.passwordConfirm && ( +

{errors.passwordConfirm}

+ )} +
+ + {/* General Error Message */} + {errors.general && ( +
+ {errors.general} +
+ )} + + + {/* Submit Button */} + +
+
+
+ ); +} diff --git a/next-vote/components/ui/button.tsx b/next-vote/components/ui/button.tsx new file mode 100644 index 0000000..15cb097 --- /dev/null +++ b/next-vote/components/ui/button.tsx @@ -0,0 +1,54 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + return ( +