Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ function AuthCallbackContent() {
export default function AuthCallback() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">로딩 중...</p>
<div className="container">
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">로딩 중...</p>
</div>
</div>
</div>
}>
Expand Down
6 changes: 4 additions & 2 deletions app/components/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ interface ContainerProps {

export default function Container({ children, className = "" }: ContainerProps) {
return (
<div className={`min-h-screen flex flex-col items-center justify-center px-4 pt-10 pb-0 text-black ${className}`}>
{children}
<div className={`container ${className}`}>
<div className="min-h-screen flex flex-col items-center justify-center px-4 pt-10 pb-0 text-black">
{children}
</div>
</div>
);
}
7 changes: 5 additions & 2 deletions app/components/NavigationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import React from "react";
import { useRouter } from "next/navigation";
import { useChild } from "../contexts/ChildContext";

interface NavigationBarProps {
activeTab?: string;
onTabChange?: (tab: string) => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
}

export default function NavigationBar({ activeTab = "홈", onTabChange }: NavigationBarProps) {
export default function NavigationBar({ activeTab = "홈", onTabChange, showToast }: NavigationBarProps) {
const router = useRouter();
const { hasChildren } = useChild();

const tabs = [
{
Expand All @@ -33,7 +36,7 @@ export default function NavigationBar({ activeTab = "홈", onTabChange }: Naviga
)
},
{
id: "아이목록",
id: "아이 목록",
label: "아이 목록",
path: "/settings",
icon: (
Expand Down
18 changes: 11 additions & 7 deletions app/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,24 @@ export default function ProtectedRoute({ children, fallback }: ProtectedRoutePro

if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#ffcc00]"></div>
<p className="text-gray-600">로딩 중...</p>
<div className="container">
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#ffcc00]"></div>
<p className="text-gray-600">로딩 중...</p>
</div>
</div>
</div>
);
}

if (!isLoggedIn) {
return fallback || (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<p className="text-gray-600">로그인이 필요합니다.</p>
<div className="container">
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<p className="text-gray-600">로그인이 필요합니다.</p>
</div>
</div>
</div>
);
Expand Down
59 changes: 59 additions & 0 deletions app/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import React, { useEffect } from 'react';

interface ToastProps {
message: string;
type: 'success' | 'error' | 'warning';
isVisible: boolean;
onClose: () => void;
}

export default function Toast({ message, type, isVisible, onClose }: ToastProps) {
useEffect(() => {
if (isVisible) {
const timer = setTimeout(() => {
onClose();
}, 3000);
return () => clearTimeout(timer);
}
}, [isVisible, onClose]);

if (!isVisible) return null;

const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-yellow-500';
const iconColor = type === 'success' ? 'bg-green-400' : type === 'error' ? 'bg-red-400' : 'bg-yellow-400';

return (
<div className={`fixed top-4 left-1/2 transform -translate-x-1/2 z-50 px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 transition-all duration-300 ${
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
} ${bgColor} text-white`}>
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${iconColor}`}>
{type === 'success' ? (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : type === 'error' ? (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
</div>
<span className="font-medium">{message}</span>
<button
onClick={onClose}
className="ml-2 text-white/80 hover:text-white"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
82 changes: 80 additions & 2 deletions app/contexts/ChildContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ interface ChildContextType {
selectedChild: ChildData | null;
isChildMode: boolean;
isLoading: boolean;
hasChildren: boolean;
setSelectedChild: (child: ChildData | null) => void;
enterChildMode: (child: ChildData) => void;
exitChildMode: () => void;
autoSelectFirstChild: () => Promise<void>;
}

const ChildContext = createContext<ChildContextType | undefined>(undefined);
Expand All @@ -24,8 +26,9 @@ export function ChildProvider({ children }: { children: React.ReactNode }) {
const [selectedChild, setSelectedChild] = useState<ChildData | null>(null);
const [isChildMode, setIsChildMode] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [hasChildren, setHasChildren] = useState(false);

// localStorage에서 상태 복원
// localStorage에서 상태 복원 및 초기 hasChildren 확인
useEffect(() => {
// 서버 사이드 렌더링 중에는 localStorage 접근하지 않음
if (typeof window === 'undefined') {
Expand Down Expand Up @@ -53,6 +56,30 @@ export function ChildProvider({ children }: { children: React.ReactNode }) {
}
}

// 초기 hasChildren 상태 확인
const checkHasChildren = async () => {
try {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const response = await fetch(`${apiBaseUrl}/api/childRelations`, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});

if (response.ok) {
const data = await response.json();
setHasChildren(data && data.length > 0);
}
} catch (error) {
console.error('hasChildren 확인 실패:', error);
setHasChildren(false);
}
};

checkHasChildren();
setIsLoading(false);

// 안전장치: 3초 후에도 로딩이 끝나지 않으면 강제로 로딩 종료
Expand Down Expand Up @@ -83,14 +110,65 @@ export function ChildProvider({ children }: { children: React.ReactNode }) {
}
};

const autoSelectFirstChild = async () => {
try {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const response = await fetch(`${apiBaseUrl}/api/childRelations`, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();

if (data && data.length > 0) {
setHasChildren(true);
const firstChild = data[0];
const birthYear = new Date(firstChild.birthdate).getFullYear();
const thisYear = new Date().getFullYear();
const age = thisYear - birthYear;

const childData: ChildData = {
id: firstChild.id,
name: firstChild.username,
age,
registeredDate: new Date(firstChild.createdAt).toLocaleDateString('ko-KR')
};

setSelectedChild(childData);
if (typeof window !== 'undefined') {
localStorage.setItem('selectedChild', JSON.stringify(childData));
}
} else {
setHasChildren(false);
// 아이가 없으면 아이 목록 페이지로 이동
if (typeof window !== 'undefined') {
window.location.href = '/settings';
}
}
} catch (error) {
console.error('자동 아이 선택 실패:', error);
setHasChildren(false);
}
};

return (
<ChildContext.Provider value={{
selectedChild,
isChildMode,
isLoading,
hasChildren,
setSelectedChild,
enterChildMode,
exitChildMode
exitChildMode,
autoSelectFirstChild
}}>
{children}
</ChildContext.Provider>
Expand Down
72 changes: 46 additions & 26 deletions app/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Container from "../components/Container"
import NavigationBar from "../components/NavigationBar"
import Toast from "../components/Toast"
import { useChild } from "../contexts/ChildContext"
import {
getForecastsByDate,
Expand Down Expand Up @@ -33,7 +34,7 @@ interface DiaryData {

export default function Register() {
const router = useRouter()
const { isChildMode, selectedChild, isLoading, exitChildMode, enterChildMode } = useChild();
const { isChildMode, selectedChild, isLoading, hasChildren, exitChildMode, enterChildMode, autoSelectFirstChild } = useChild();
const [childName, setChildName] = useState('')
const [activeTab, setActiveTab] = useState('홈')
const [selectedDate, setSelectedDate] = useState<string | null>(null)
Expand All @@ -42,6 +43,15 @@ export default function Register() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [diaryData, setDiaryData] = useState<Record<string, DiaryData>>({})
const [toast, setToast] = useState<{
message: string;
type: 'success' | 'error' | 'warning';
isVisible: boolean;
}>({
message: '',
type: 'warning',
isVisible: false
})

const generateCalendarDays = () => {
const year = currentMonth.getFullYear();
Expand Down Expand Up @@ -210,6 +220,33 @@ export default function Register() {
}
}, [selectedChild?.name]);

// 토스트 메시지 표시 함수
const showToast = (message: string, type: 'success' | 'error' | 'warning') => {
setToast({
message,
type,
isVisible: true
});
};

const hideToast = () => {
setToast(prev => ({ ...prev, isVisible: false }));
};

// 선택된 아이가 없을 때 자동 선택 시도
useEffect(() => {
if (!isLoading && !selectedChild) {
autoSelectFirstChild();
}
}, [isLoading, selectedChild, autoSelectFirstChild]);

// 아이가 없을 때 토스트 메시지 표시
useEffect(() => {
if (!isLoading && !hasChildren && !selectedChild) {
showToast('이동할 수 없습니다. 아이를 생성하거나 연결해주세요.', 'warning');
}
}, [isLoading, hasChildren, selectedChild]);

// 아이 모드 전환 함수
const handleEnterChildMode = () => {
if (selectedChild) {
Expand Down Expand Up @@ -244,33 +281,16 @@ export default function Register() {
);
}

// 선택된 아이가 없을 때
if (!selectedChild) {
return (
<Container className="bg-white">
<div className="flex flex-col items-center justify-center flex-grow w-full max-w-sm mx-auto mt-4">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<p className="text-gray-600 text-lg font-medium mb-2">선택된 아이가 없습니다</p>
<p className="text-gray-500 text-sm text-center mb-4">
아이 목록에서 아이를 선택해주세요
</p>
<button
onClick={() => router.push('/settings')}
className="px-6 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600"
>
아이 목록으로 이동
</button>
</div>
</Container>
);
}


return (
<Container className="bg-white">
<Toast
message={toast.message}
type={toast.type}
isVisible={toast.isVisible}
onClose={hideToast}
/>
<div className="flex flex-col items-start justify-start flex-grow w-full max-w-sm mx-auto mt-4">
<div className="w-full flex justify-between items-center mb-4">
<span className="text-gray-900 font-semibold text-2xl">{displayChildName}</span>
Expand Down Expand Up @@ -480,7 +500,7 @@ export default function Register() {
)}
</div>
{/* 네비게이션바는 보호자 모드에서만 노출 */}
{!isChildMode && <NavigationBar activeTab={activeTab} onTabChange={setActiveTab} />}
{!isChildMode && <NavigationBar activeTab={activeTab} onTabChange={setActiveTab} showToast={showToast} />}
</Container>
)
}
Loading