diff --git a/.env b/.env new file mode 100644 index 0000000..5b790e2 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +# API 서버 URL (기본값: 프로덕션 서버) +NEXT_PUBLIC_API_URL=https://srv.comatching.site + +# Firebase Config (예시 값 - 실제 값은 .env.local에 작성) +NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your-sender-id +NEXT_PUBLIC_FIREBASE_APP_ID=your-app-id +NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your-measurement-id +NEXT_PUBLIC_FIREBASE_VAPID_KEY=your-vapid-key + +# 로컬 개발 환경 설정은 .env.local 파일에 작성하세요 diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 20e93f9..2a7a02b 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -6,13 +6,20 @@ - Even if the code or comments are in English, your review and feedback MUST be in Korean. - 코드가 영어로 작성되어 있더라도, 리뷰와 피드백은 반드시 한국어로 작성하세요. +Technology Stack: + +- **이 프로젝트는 React 19와 Next.js 16을 사용합니다.** +- **모든 코드 리뷰는 반드시 React 19와 Next.js 16의 최신 기능과 Best Practice를 기준으로 작성되어야 합니다.** + Persona: Act as a Senior Front-end Engineer specializing in Next.js 16 & React 19. Be precise, insightful, and strict about performance, web standards, and maintainability. Tone: Polite but professional. (정중하되, 문제는 명확하게 지적하고 구체적인 해결책을 제시하세요.) 2. React 19 & Next.js 16 Architecture - 2.1 React 19 Core Features - Hooks & Compilation: React Compiler 도입을 고려하여, 불필요한 useMemo, useCallback은 제거를 권장하세요. + **CRITICAL: 이 프로젝트는 React 19와 Next.js 16을 사용합니다. 모든 리뷰는 이 버전의 최신 기능과 API를 기준으로 작성되어야 합니다.** + +2.1 React 19 Core Features +Hooks & Compilation: React Compiler 도입을 고려하여, 불필요한 useMemo, useCallback은 제거를 권장하세요. Form Actions: useActionState(구 useFormState)와 useFormStatus를 활용하여 폼 상태와 로딩 UI를 선언적으로 관리하는지 확인하세요. @@ -24,9 +31,11 @@ forwardRef 대신 ref를 prop으로 직접 전달하는지 확인하세요. use() 훅을 사용하여 Promise나 Context를 조건부로 읽어오는지 확인하세요. -2.2 Next.js Architecture (App Router) +2.2 Next.js 16 Architecture (App Router) Async Request APIs: params, searchParams, cookies(), headers() 등이 반드시 await로 비동기 처리되었는지 엄격히 확인하세요 (Next.js 15+ 필수 사항). +- **Next.js 16에서는 이러한 API들이 모두 Promise를 반환합니다. await 없이 사용하는 코드를 발견하면 반드시 지적하세요.** + Metadata API: 태그를 직접 사용하기보다, Next.js의 metadata 객체나 generateMetadata 함수를 사용하여 SEO 태그를 관리하는지 확인하세요. Caching Strategy: fetch의 캐싱 동작을 이해하고, 필요 시 cache: 'force-cache' 옵션이나 React 19의 'use cache' 디렉티브가 명시되었는지 확인하세요. diff --git a/.gitignore b/.gitignore index 57ca4e1..e98b395 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,11 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# env files +.env*.local +.env.local +.env.development.local +.env.production.local # vercel .vercel diff --git a/app/_components/LoginActionSection.tsx b/app/_components/LoginActionSection.tsx index 27b061b..07202f5 100644 --- a/app/_components/LoginActionSection.tsx +++ b/app/_components/LoginActionSection.tsx @@ -22,7 +22,7 @@ export default function ScreenLoginActionSection() { return ( <section className="flex flex-col items-center"> - <BubbleDiv /> + <BubbleDiv top={5} /> <KakaoLoginButton className="mt-[1.6vh] mb-[0.49vh]" onClick={handleKakaoLogin} diff --git a/app/layout.tsx b/app/layout.tsx index 2290d4f..f03968c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -26,12 +26,13 @@ export const metadata: Metadata = { type: "website", locale: "ko_KR", }, - viewport: { - width: "device-width", - initialScale: 1, - maximumScale: 1, - userScalable: false, - }, +}; + +export const viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, }; export default async function RootLayout({ diff --git a/app/login/_components/LoginForm.tsx b/app/login/_components/LoginForm.tsx index 2d97e91..842a469 100644 --- a/app/login/_components/LoginForm.tsx +++ b/app/login/_components/LoginForm.tsx @@ -1,18 +1,12 @@ "use client"; import BubbleDiv from "@/app/_components/BubbleDiv"; import Button from "@/components/ui/Button"; +import FormInput from "@/components/ui/FormInput"; import { User } from "lucide-react"; -import React, { useActionState } from "react"; +import React, { useActionState, useState } from "react"; import Link from "next/link"; import { loginAction } from "@/lib/actions/loginAction"; -const INPUT_STYLE = { - background: - "linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)", -}; -const INPUT_CLASSNAME = - "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] placeholder:text-[#B3B3B3]"; - export const LoginForm = () => { // React 19: useActionState로 폼 상태 및 팬딩 처리 관리 const [state, formAction, isPending] = useActionState(loginAction, { @@ -20,6 +14,8 @@ export const LoginForm = () => { message: "", }); + const [email, setEmail] = useState(""); + return ( <section className="mt-10 flex w-full flex-1 flex-col items-start gap-6"> <form className="flex w-full flex-col gap-4" action={formAction}> @@ -27,15 +23,15 @@ export const LoginForm = () => { <label htmlFor="email" className="typo-14-500 text-gray-700"> 아이디(이메일) </label> - <input + <FormInput id="email" type="email" name="email" placeholder="이메일 입력" required autoComplete="email" - className={INPUT_CLASSNAME} - style={INPUT_STYLE} + value={email} + onChange={(e) => setEmail(e.target.value)} /> </div> @@ -43,17 +39,15 @@ export const LoginForm = () => { <label htmlFor="password" className="typo-14-500 text-gray-700"> 비밀번호 </label> - <input + <FormInput id="password" type="password" name="password" placeholder="비밀번호 입력" required autoComplete="current-password" - className={INPUT_CLASSNAME} - style={INPUT_STYLE} /> - {!state.success && ( + {!state.success && state.message && ( <span className="typo-12-400 text-color-flame-700 absolute bottom-[-25px] left-0"> * 이메일 혹은 비밀번호가 틀립니다 </span> @@ -77,13 +71,13 @@ export const LoginForm = () => { <BubbleDiv w={162} h={26} typo="typo-12-600" top={3}> 아직 계정이 없으신가요?! </BubbleDiv> - <button - type="button" + <Link + href="/register" className="typo-14-500 flex items-center gap-1 border-b-2 border-gray-500 text-gray-500" > <User /> 이메일로 회원가입 - </button> + </Link> </div> </section> ); diff --git a/app/login/_components/ScreenLocalLoginPage.tsx b/app/login/_components/ScreenLocalLoginPage.tsx index f5d5a9f..0ea323f 100644 --- a/app/login/_components/ScreenLocalLoginPage.tsx +++ b/app/login/_components/ScreenLocalLoginPage.tsx @@ -5,7 +5,7 @@ import { LoginForm } from "./LoginForm"; const ScreenLocalLoginPage = () => { return ( - <main className="flex h-dvh flex-col items-start px-4 pt-2 pb-[6.2vh]"> + <main className="flex min-h-dvh flex-col items-start px-4 pt-2 pb-[6.2vh]"> <BackButton /> <LocalLoginIntro /> <LoginForm /> diff --git a/components/ui/FormInput.tsx b/components/ui/FormInput.tsx new file mode 100644 index 0000000..45d0100 --- /dev/null +++ b/components/ui/FormInput.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +// React.InputHTMLAttributes를 확장하여 모든 표준 input 속성을 타입 안전하게 지원 +interface FormInputProps extends Omit< + React.InputHTMLAttributes<HTMLInputElement>, + "id" | "type" | "name" | "placeholder" +> { + id: string; // input 요소의 고유 식별자 (label의 htmlFor와 연결) + type: string; // input 타입 (예: text, email, password 등) + name: string; // form 데이터 전송 시 key 역할 + placeholder: string; // 입력란에 표시되는 안내 텍스트 +} + +const INPUT_STYLE = { + background: + "linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)", +}; +const INPUT_CLASSNAME = + "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] typo-16-500 placeholder:text-[#B3B3B3] text-color-gray-900 outline-none"; + +// 안전한 속성 화이트리스트 (XSS 방지) +const SAFE_INPUT_ATTRIBUTES = [ + "autoComplete", + "required", + "value", + "defaultValue", + "disabled", + "readOnly", + "maxLength", + "minLength", + "max", + "min", + "pattern", + "step", + "inputMode", + "aria-label", + "aria-describedby", + "aria-invalid", + "aria-required", + "onChange", + "onBlur", + "onFocus", + "onInput", + "onKeyDown", + "onKeyUp", + "ref", // React 19에서 ref는 일반 prop +] as const; + +type SafeInputAttribute = (typeof SAFE_INPUT_ATTRIBUTES)[number]; + +// React 19: ref는 일반 prop으로 전달되므로 forwardRef 불필요 +const FormInput = ({ + id, + type, + name, + placeholder, + className = "", + style = {}, + ...rest +}: FormInputProps) => { + // 화이트리스트에 있는 안전한 속성만 추출 + const safeProps = Object.fromEntries( + Object.entries(rest).filter(([key]) => + SAFE_INPUT_ATTRIBUTES.includes(key as SafeInputAttribute), + ), + ); + + return ( + <input + id={id} + type={type} + name={name} + placeholder={placeholder} + className={cn(INPUT_CLASSNAME, className)} + style={{ ...INPUT_STYLE, ...style }} + {...safeProps} + /> + ); +}; + +export default FormInput; diff --git a/lib/status.ts b/lib/status.ts index 66b120c..a9a5607 100644 --- a/lib/status.ts +++ b/lib/status.ts @@ -1,7 +1,7 @@ export async function getInitialMaintenanceStatus() { try { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/status`, { - cache: "no-store", + next: { revalidate: 60 }, // 60초마다 재검증 (정적 생성 가능) }); if (!res.ok) return false;