= isAuthed
+ ? {
+ isAuthed: true as const,
+ userName: userName!,
+ onLogoutClick: () => setConfirmOpen(true),
+ }
+ : {
+ onLoginClick: () => setLoginOpen(true),
+ };
+
return (
{/* Header */}
-
+
{/* Hero */}
@@ -42,6 +83,89 @@ export default function Home() {
+
+ {/* 로그인 모달 */}
+ setLoginOpen(false)}
+ onSignupClick={() => {
+ setLoginOpen(false);
+ setSignupOpen(true);
+ }}
+ onSubmit={async ({ email, password }) => {
+ try {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ },
+ );
+
+ const data = await res.json();
+
+ if (!res.ok) throw new Error(data.error || '로그인 실패');
+
+ localStorage.setItem('accessToken', data.data.accessToken);
+ localStorage.setItem('refreshToken', data.data.refreshToken);
+ if (data?.data?.user?.username) {
+ localStorage.setItem('userName', data.data.user.username);
+ setUserName(data.data.user.username);
+ }
+ setIsAuthed(true);
+ return true;
+ } catch (err) {
+ alert(err instanceof Error ? err.message : '로그인 중 오류 발생');
+ return false; // 실패 시 false 반환
+ }
+ }}
+ />
+
+ {/* 회원가입 모달 */}
+ setSignupOpen(false)}
+ onLoginClick={() => {
+ setSignupOpen(false);
+ setLoginOpen(true);
+ }}
+ onSubmit={async ({ username, email, password }) => {
+ try {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/register`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, email, password }),
+ },
+ );
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || '회원가입 실패');
+ }
+
+ console.log('회원가입 성공', data.data.user);
+ alert('회원가입이 완료되었습니다. 로그인 해주세요.');
+ setSignupOpen(false);
+ setLoginOpen(true); // 바로 로그인 유도
+ } catch (err) {
+ alert(err instanceof Error ? err.message : '회원가입 중 오류 발생');
+ }
+ }}
+ />
+
+ {/* 로그아웃 확인 모달 */}
+ setConfirmOpen(false)}
+ />
);
}
diff --git a/src/app/result/page.tsx b/src/app/result/page.tsx
index 8876c6e..74a06b0 100644
--- a/src/app/result/page.tsx
+++ b/src/app/result/page.tsx
@@ -5,7 +5,7 @@ import React, { useState, useEffect } from 'react';
import { RefreshCw, Clock, Info } from 'lucide-react';
import AlarmModal, { AlarmData } from '@/components/AlarmModal';
import { useSearchParams } from 'next/navigation';
-import KoreanStandardTime from '@/components/KoreanStandaradTime';
+import KoreanStandardTime from '@/components/KoreanStandardTime';
import ServerSearchForm from '@/components/ServerSearchForm';
import AlarmCountdown from '@/components/AlarmCountdown';
@@ -349,7 +349,7 @@ export default function CheckTimeApp() {
// 1. /api/time/compare 엔드포인트 호출
const compareResponse = await fetch(
- 'http://localhost:3001/api/time/compare',
+ `${process.env.NEXT_PUBLIC_API_BASE}/api/time/compare`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
diff --git a/src/components/KoreanStandaradTime.tsx b/src/components/KoreanStandardTime.tsx
similarity index 90%
rename from src/components/KoreanStandaradTime.tsx
rename to src/components/KoreanStandardTime.tsx
index e999760..8b83e7c 100644
--- a/src/components/KoreanStandaradTime.tsx
+++ b/src/components/KoreanStandardTime.tsx
@@ -16,7 +16,18 @@ export default function KoreanStandardTime({
const fetchTime = async () => {
try {
- const res = await fetch('http://localhost:3001/api/time/current'); // 배포 시 서버 주소로 변경
+ const apiPath = `/api/time/current`;
+ const base = process.env.NEXT_PUBLIC_API_BASE;
+ const url = base ? new URL(apiPath, base).toString() : apiPath;
+
+ const res = await fetch(url, { cache: 'no-store' }); // 캐시를 사용하지 않도록 설정
+
+ if (!res.ok) {
+ throw new Error(
+ `시간 API 호출 실패: ${res.status} ${res.statusText}`,
+ );
+ }
+
const data = await res.json();
if (data.success && data.data.timestamp) {
diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx
new file mode 100644
index 0000000..ca4527d
--- /dev/null
+++ b/src/components/auth/LoginModal.tsx
@@ -0,0 +1,174 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ onSubmit?: (payload: {
+ email: string;
+ password: string;
+ }) => Promise | boolean;
+ onSignupClick?: () => void; // 회원가입 열기
+};
+
+export default function LoginModal({
+ open,
+ onClose,
+ onSubmit,
+ onSignupClick,
+}: Props) {
+ const [showPw, setShowPw] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const fd = new FormData(e.currentTarget);
+ const email = String(fd.get('email') || '');
+ const password = String(fd.get('password') || '');
+ setLoading(true);
+ try {
+ const ok = await onSubmit?.({ email, password });
+ if (ok) onClose(); // 로그인 성공 시 모달 닫기
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ {/* backdrop (배경 클릭으로 닫기) */}
+
+
+ {/* panel */}
+
+ {/* 닫기 버튼 */}
+
+
+ {/* 로고 섹션 (logo-section) */}
+
+
+ {/* 로그인 폼 (login-form) */}
+
+
+ {/* 구분선 (divider) */}
+
+
+
+
+ {/* 하단 링크 (bottom-link) */}
+
+ 아직 계정이 없으신가요?{' '}
+
+
+
+
+ );
+}
diff --git a/src/components/auth/SignupModal.tsx b/src/components/auth/SignupModal.tsx
new file mode 100644
index 0000000..c6aa418
--- /dev/null
+++ b/src/components/auth/SignupModal.tsx
@@ -0,0 +1,252 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ onSubmit?: (payload: {
+ username: string;
+ email: string;
+ password: string;
+ }) => Promise | void;
+ onLoginClick?: () => void; // 로그인 모달 열기
+};
+
+export default function SignupModal({
+ open,
+ onClose,
+ onSubmit,
+ onLoginClick,
+}: Props) {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPw, setShowPw] = useState(false);
+ const [showConfirmPw, setShowConfirmPw] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const passwordStrength = (() => {
+ let score = 0;
+ if (password.length >= 8) score++;
+ if (/[a-zA-Z]/.test(password)) score++;
+ if (/[0-9]/.test(password)) score++;
+ if (/[^a-zA-Z0-9]/.test(password)) score++;
+ return score;
+ })();
+
+ const strengthBarClass =
+ passwordStrength <= 1
+ ? 'bg-red-500 w-1/4'
+ : passwordStrength === 2
+ ? 'bg-yellow-500 w-1/2'
+ : passwordStrength === 3
+ ? 'bg-blue-500 w-3/4'
+ : 'bg-green-500 w-full';
+
+ const canSubmit =
+ username.trim().length >= 2 &&
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) &&
+ passwordStrength >= 3 &&
+ password === confirmPassword &&
+ !loading;
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!canSubmit) return;
+ setLoading(true);
+ try {
+ await onSubmit?.({
+ username: username.trim(),
+ email,
+ password,
+ });
+ onClose(); // 회원가입 성공 시 모달 닫기
+ } catch (err) {
+ alert(
+ err instanceof Error ? err.message : '회원가입 중 오류가 발생했습니다.',
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ {/* backdrop */}
+
+ {/* panel */}
+
+ {/* 닫기 버튼 */}
+
+ {/* 로고 섹션 */}
+
+ {/* 폼 */}
+
+
+ 이미 계정이 있으신가요?{' '}
+
+
+
+
+ );
+}
diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx
new file mode 100644
index 0000000..677bceb
--- /dev/null
+++ b/src/components/ui/ConfirmModal.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import React, { useEffect, useRef } from 'react';
+
+type Props = {
+ open: boolean;
+ title?: string;
+ message: string;
+ confirmText?: string;
+ cancelText?: string;
+ onConfirm: () => void;
+ onClose: () => void; // 취소/닫기
+};
+
+export default function ConfirmModal({
+ open,
+ title = '확인',
+ message,
+ confirmText = '확인',
+ cancelText = '취소',
+ onConfirm,
+ onClose,
+}: Props) {
+ const dialogRef = useRef(null);
+ const firstBtnRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ // 열릴 때 포커스 이동
+ firstBtnRef.current?.focus();
+
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ onClose();
+ }
+ };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
+ {/* overlay: 클릭 시 닫힘 */}
+
+
+ {/* dialog */}
+
+
+ {title}
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+
+
+
+
+ {/* 우측 상단 닫기 버튼 */}
+
+
+
+ );
+}
diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx
index 81e8821..f569e5a 100644
--- a/src/components/ui/Header.tsx
+++ b/src/components/ui/Header.tsx
@@ -4,14 +4,27 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
import clsx from 'clsx';
-export default function Header() {
+type AuthedProps = {
+ isAuthed: true;
+ userName: string;
+ onLoginClick?: never;
+ onLogoutClick: () => void; // 로그아웃 함수
+};
+
+type GuestProps = {
+ isAuthed?: false;
+ userName?: never;
+ onLoginClick: () => void; // 로그인 모달 열기
+ onLogoutClick?: never;
+};
+
+type HeaderProps = AuthedProps | GuestProps;
+
+export default function Header(props: HeaderProps) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
- const handleScroll = () => {
- setScrolled(window.scrollY > 100);
- };
-
+ const handleScroll = () => setScrolled(window.scrollY > 100);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
@@ -53,20 +66,32 @@ export default function Header() {
- {/* 액션 버튼 */}
+ {/* 액션 */}
-
- 로그인
-
-
- 시작하기
-
+ {props.isAuthed ? (
+ <>
+ {props.userName && (
+
+ 안녕하세요, {props.userName}님
+
+ )}
+
+ >
+ ) : (
+
+ )}