From 4ae7ea739a4d4f60f116652abed3738fe2181ce9 Mon Sep 17 00:00:00 2001 From: cherry2250 Date: Mon, 27 Oct 2025 16:37:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=8A=A4=ED=94=BC?= =?UTF-8?q?=EB=84=88=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContentPreviewStep과 DrawPage의 로딩 스피너를 그라데이션 디자인으로 변경 - 보라색-핑크-파란색 그라데이션 회전 애니메이션 적용 - 중앙 아이콘에 펄스 애니메이션 추가 - DrawPage 다이얼로그 배경을 밝은 배경으로 변경하여 일관성 향상 --- src/app/(main)/events/[eventId]/draw/page.tsx | 47 +++++++++++++++---- .../create/steps/ContentPreviewStep.tsx | 39 ++++++++++++--- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/app/(main)/events/[eventId]/draw/page.tsx b/src/app/(main)/events/[eventId]/draw/page.tsx index 4f10d86..e9d09fe 100644 --- a/src/app/(main)/events/[eventId]/draw/page.tsx +++ b/src/app/(main)/events/[eventId]/draw/page.tsx @@ -604,28 +604,55 @@ export default function DrawPage() { fullWidth PaperProps={{ sx: { - bgcolor: 'rgba(0, 0, 0, 0.9)', - color: 'white', + bgcolor: 'background.paper', borderRadius: 4, }, }} > - + > + + {animationText} - + {animationSubtext} diff --git a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx index b210c5d..5a2f7df 100644 --- a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx +++ b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx @@ -122,18 +122,45 @@ export default function ContentPreviewStep({ - + > + + AI 이미지 생성 중 From 08777aa00d20472b98d3ff9c3864443c2926e664 Mon Sep 17 00:00:00 2001 From: cherry2250 Date: Mon, 27 Oct 2025 17:38:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EC=B9=B4=EB=93=9C=20=EC=9A=94=EC=86=8C=20=EC=A4=91?= =?UTF-8?q?=EC=95=99=20=EC=A0=95=EB=A0=AC=20(320px=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/analytics/page.tsx | 206 ++-- src/app/(main)/events/[eventId]/page.tsx | 292 +++--- src/app/(main)/events/page.tsx | 1171 ++++++++++++---------- src/app/(main)/page.tsx | 166 +-- src/shared/lib/button-styles.ts | 40 +- 5 files changed, 1014 insertions(+), 861 deletions(-) diff --git a/src/app/(main)/analytics/page.tsx b/src/app/(main)/analytics/page.tsx index a94244f..0703425 100644 --- a/src/app/(main)/analytics/page.tsx +++ b/src/app/(main)/analytics/page.tsx @@ -135,24 +135,24 @@ export default function AnalyticsPage() { minHeight: '100vh', }} > - + {/* Title with Real-time Indicator */} 📊 요약 (실시간) - + {/* Summary KPI Cards */} - + - + 참여자 수 - + {summary.participants} - ↑ {summary.participantsDelta}명 (오늘) + ↑ {summary.participantsDelta}명 @@ -212,27 +212,26 @@ export default function AnalyticsPage() { borderColor: 'transparent', }} > - + 총 비용 - + {Math.floor(summary.totalCost / 10000)}만 - 경품 {Math.floor(roiDetail.prizeCost / 10000)}만 + 채널{' '} - {Math.floor(roiDetail.channelCost / 10000)}만 + 경품+채널 @@ -246,27 +245,26 @@ export default function AnalyticsPage() { borderColor: 'transparent', }} > - + 예상 수익 - + {Math.floor(summary.expectedRevenue / 10000)}만 - 매출 {Math.floor(roiDetail.salesIncrease / 10000)}만 + LTV{' '} - {Math.floor(roiDetail.newCustomerLTV / 10000)}만 + 매출+LTV @@ -280,26 +278,26 @@ export default function AnalyticsPage() { borderColor: 'transparent', }} > - + - 투자대비수익률 + ROI - + {summary.roi}% - 목표 {summary.targetRoi}% 달성 + 목표 {summary.targetRoi}% @@ -307,16 +305,16 @@ export default function AnalyticsPage() { {/* Charts Grid */} - + {/* Channel Performance */} - - + + - + - + 채널별 성과 @@ -335,9 +333,9 @@ export default function AnalyticsPage() { {/* Legend */} - + {channelPerformance.map((item) => ( - + {item.channel} - + {item.percentage}% ({item.participants}명) @@ -412,12 +410,12 @@ export default function AnalyticsPage() { {/* Time Trend */} - - + + - + - + 시간대별 참여 추이 @@ -436,11 +434,11 @@ export default function AnalyticsPage() { - + 피크 시간: {timePerformance.peakTime} ({timePerformance.peakParticipants}명) - + 평균 시간당: {timePerformance.avgPerHour}명 @@ -527,16 +525,16 @@ export default function AnalyticsPage() { {/* ROI Detail & Participant Profile */} - + {/* ROI Detail */} - - + + - + - - 투자대비수익률 상세 + + ROI 상세 - + - + 총 비용: {Math.floor(roiDetail.totalCost / 10000)}만원 - + - + • 경품 비용 - + {Math.floor(roiDetail.prizeCost / 10000)}만원 - + • 채널 비용 - + {Math.floor(roiDetail.channelCost / 10000)}만원 @@ -577,23 +575,23 @@ export default function AnalyticsPage() { - + 예상 수익: {Math.floor(roiDetail.expectedRevenue / 10000)}만원 - + - + • 매출 증가 - + {Math.floor(roiDetail.salesIncrease / 10000)}만원 - + • 신규 고객 LTV - + {Math.floor(roiDetail.newCustomerLTV / 10000)}만원 @@ -601,26 +599,26 @@ export default function AnalyticsPage() { - + 투자대비수익률 - + (수익 - 비용) ÷ 비용 × 100 - + ({Math.floor(roiDetail.expectedRevenue / 10000)}만 -{' '} {Math.floor(roiDetail.totalCost / 10000)}만) ÷{' '} {Math.floor(roiDetail.totalCost / 10000)}만 × 100 - + = {summary.roi}% @@ -633,12 +631,12 @@ export default function AnalyticsPage() { {/* Participant Profile */} - - + + - + - + 참여자 프로필 {/* Age Distribution */} - - + + 연령별 - + {participantProfile.age.map((item) => ( - - + + {item.label} {item.percentage}% @@ -701,20 +699,20 @@ export default function AnalyticsPage() { {/* Gender Distribution */} - + 성별 - + {participantProfile.gender.map((item) => ( - - + + {item.label} {item.percentage}% diff --git a/src/app/(main)/events/[eventId]/page.tsx b/src/app/(main)/events/[eventId]/page.tsx index 578ecd4..fed6ccd 100644 --- a/src/app/(main)/events/[eventId]/page.tsx +++ b/src/app/(main)/events/[eventId]/page.tsx @@ -254,12 +254,12 @@ export default function EventDetailPage() { }; return ( - - + + {/* Event Header */} - - - + + + {event.title} @@ -282,61 +282,62 @@ export default function EventDetailPage() { - + {event.isAIRecommended && ( - + )} {event.isUrgent && ( } + icon={} label="마감임박" size="medium" - sx={{ bgcolor: '#FEF3C7', color: '#92400E' }} + sx={{ bgcolor: '#FEF3C7', color: '#92400E', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }} /> )} {event.isPopular && ( } + icon={} label="인기" size="medium" - sx={{ bgcolor: '#FEE2E2', color: '#991B1B' }} + sx={{ bgcolor: '#FEE2E2', color: '#991B1B', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }} /> )} {event.isHighROI && ( } + icon={} label="높은 ROI" size="medium" - sx={{ bgcolor: '#DCFCE7', color: '#166534' }} + sx={{ bgcolor: '#DCFCE7', color: '#166534', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }} /> )} {event.isNew && ( } + icon={} label="신규" size="medium" - sx={{ bgcolor: '#DBEAFE', color: '#1E40AF' }} + sx={{ bgcolor: '#DBEAFE', color: '#1E40AF', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }} /> )} - + 📅 {event.startDate} ~ {event.endDate} {/* 진행률 바 (진행중인 이벤트만) */} {event.status === 'active' && ( - - + + 이벤트 진행률 - + {Math.round(calculateProgress(event))}% @@ -344,7 +345,7 @@ export default function EventDetailPage() { variant="determinate" value={calculateProgress(event)} sx={{ - height: 10, + height: { xs: 6, sm: 10 }, borderRadius: 5, bgcolor: colors.gray[100], '& .MuiLinearProgress-bar': { @@ -358,16 +359,16 @@ export default function EventDetailPage() { {/* Real-time KPIs */} - - - + + + 실시간 현황 - + - + 실시간 업데이트 - + - - - + + + 참여자 - + {event.participants}명 - - 목표: {event.targetParticipants}명 ( - {Math.round((event.participants / event.targetParticipants) * 100)}%) + + 목표: {event.targetParticipants}명
+ ({Math.round((event.participants / event.targetParticipants) * 100)}%)
@@ -412,19 +422,29 @@ export default function EventDetailPage() { - - - + + + 조회수 - + {event.views} +
@@ -432,19 +452,29 @@ export default function EventDetailPage() { - - - + + + ROI - + {event.roi}% +
@@ -452,19 +482,29 @@ export default function EventDetailPage() { - - - + + + 전환율 - + {event.conversion}% +
@@ -472,18 +512,18 @@ export default function EventDetailPage() {
{/* Chart Section - 참여 추이 */} - - + + 📈 참여 추이 - - - + + + @@ -491,7 +531,7 @@ export default function EventDetailPage() { size="medium" variant={chartPeriod === '30d' ? 'contained' : 'outlined'} onClick={() => setChartPeriod('30d')} - sx={{ borderRadius: 2 }} + sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }} > 30일 @@ -499,13 +539,13 @@ export default function EventDetailPage() { size="medium" variant={chartPeriod === 'all' ? 'contained' : 'outlined'} onClick={() => setChartPeriod('all')} - sx={{ borderRadius: 2 }} + sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }} > 전체 - + {/* Chart Section - 채널별 성과 & ROI 추이 */} - + - + 📊 채널별 참여자 - - - + + + - + 💰 ROI 추이 - - - + + + {/* Event Details */} - - + + 🎯 이벤트 정보 - - - - + + + + - + 경품 - + {event.prize} - - + + {getMethodIcon(event.method)} - + 참여 방법 - + {event.method} - - - + + + - + 예상 비용 - + {event.cost.toLocaleString()}원 - - - + + + - + 배포 채널 - + {event.channels.map((channel) => ( ))} @@ -710,17 +752,17 @@ export default function EventDetailPage() { {/* Quick Actions */} - - + + ⚡ 빠른 작업 - + router.push(`/events/${eventId}/participants`)} > - - - + + + 참여자 목록 @@ -743,7 +785,7 @@ export default function EventDetailPage() { elevation={0} sx={{ cursor: 'pointer', - borderRadius: 4, + borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', transition: 'all 0.2s', '&:hover': { @@ -752,9 +794,9 @@ export default function EventDetailPage() { }, }} > - - - + + + 이벤트 수정 @@ -765,7 +807,7 @@ export default function EventDetailPage() { elevation={0} sx={{ cursor: 'pointer', - borderRadius: 4, + borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', transition: 'all 0.2s', '&:hover': { @@ -774,9 +816,9 @@ export default function EventDetailPage() { }, }} > - - - + + + 공유하기 @@ -787,7 +829,7 @@ export default function EventDetailPage() { elevation={0} sx={{ cursor: 'pointer', - borderRadius: 4, + borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', transition: 'all 0.2s', '&:hover': { @@ -796,9 +838,9 @@ export default function EventDetailPage() { }, }} > - - - + + + 데이터 다운 @@ -808,32 +850,32 @@ export default function EventDetailPage() { {/* Recent Participants */} - - - + + + 👥 최근 참여자 - - + + {recentParticipants.map((participant, index) => ( - {index > 0 && } + {index > 0 && } - + - + - + {participant.name} - + {participant.phone} - + {participant.time} diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx index 1a2dea4..bf80422 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -36,11 +36,7 @@ import { Star, } from '@mui/icons-material'; import Header from '@/shared/ui/Header'; -import { - cardStyles, - colors, - responsiveText, -} from '@/shared/lib/button-styles'; +import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles'; // Mock 데이터 const mockEvents = [ @@ -208,7 +204,7 @@ export default function EventsPage() { } }; - const calculateProgress = (event: typeof mockEvents[0]) => { + const calculateProgress = (event: (typeof mockEvents)[0]) => { if (event.status !== 'active') return 0; const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime(); const elapsed = Date.now() - new Date(event.startDate).getTime(); @@ -237,552 +233,647 @@ export default function EventsPage() { minHeight: '100vh', }} > - - {/* Summary Statistics */} - - - - - - - {stats.total} - - - 전체 이벤트 - - - - - - - - - - {stats.active} - - - 진행중 - - - - - - - - - - {stats.totalParticipants} - - - 총 참여자 - - - - - - - - - - {stats.avgROI}% - - - 평균 ROI - - - - - - - {/* Search Section */} - - setSearchTerm(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: 3, - bgcolor: 'white', - '& fieldset': { - borderColor: colors.gray[200], - }, - '&:hover fieldset': { - borderColor: colors.gray[300], - }, - '&.Mui-focused fieldset': { - borderColor: colors.purple, - }, - }, - }} - /> - - - {/* Filters */} - - - - - 상태 - - - - 기간 - - - - - - {/* Sorting */} - - - - 정렬 - - - - - - - - {/* Event List */} - - {pageEvents.length === 0 ? ( - - - - - event_busy - - - - 검색 결과가 없습니다 - - - 다른 검색 조건으로 다시 시도해보세요 - - - - ) : ( - - {pageEvents.map((event) => ( - handleEventClick(event.id)} + - - {/* Header with Badges */} - - - - {event.title} - - - {getStatusText(event.status)} - {event.status === 'active' - ? ` | D-${event.daysLeft}` - : event.status === 'scheduled' - ? ` | D+${event.daysLeft}` - : ''} - - + + + {stats.total} + + + 전체 이벤트 + + + + + + + + + + {stats.active} + + + 진행중 + + + + + + + + + + {stats.totalParticipants} + + + 총 참여자 + + + + + + + + + + {stats.avgROI}% + + + 평균 ROI + + + + + - {/* Status Badges */} - - {event.isUrgent && ( - } - label="마감임박" - size="small" - sx={{ - bgcolor: '#FEF3C7', - color: '#92400E', - fontWeight: 600, - fontSize: '0.75rem', - '& .MuiChip-icon': { color: '#92400E' }, - }} - /> - )} - {event.isPopular && ( - } - label="인기" - size="small" - sx={{ - bgcolor: '#FEE2E2', - color: '#991B1B', - fontWeight: 600, - fontSize: '0.75rem', - '& .MuiChip-icon': { color: '#991B1B' }, - }} - /> - )} - {event.isHighROI && ( - } - label="높은 ROI" - size="small" - sx={{ - bgcolor: '#DCFCE7', - color: '#166534', - fontWeight: 600, - fontSize: '0.75rem', - '& .MuiChip-icon': { color: '#166534' }, - }} - /> - )} - {event.isNew && ( - } - label="신규" - size="small" - sx={{ - bgcolor: '#DBEAFE', - color: '#1E40AF', - fontWeight: 600, - fontSize: '0.75rem', - '& .MuiChip-icon': { color: '#1E40AF' }, - }} - /> - )} - - - - {/* Progress Bar for Active Events */} - {event.status === 'active' && ( - - - - 이벤트 진행률 - - - {Math.round(calculateProgress(event))}% - - - - - )} - - {/* Event Info and Stats Container */} - - {/* Left: Event Info */} - - - - - - {event.prize} - - - - {getMethodIcon(event.method)} - - {event.method} - - - - - {/* Date */} - - 📅 - - {event.startDate} ~ {event.endDate} - - - - - {/* Right: Stats */} - - - - 참여자 - - - {event.participants.toLocaleString()} - - 명 - - - {event.targetParticipants > 0 && ( - - 목표: {event.targetParticipants}명 ({Math.round((event.participants / event.targetParticipants) * 100)}%) - - )} - - - - ROI - - = 400 ? colors.mint : event.roi >= 200 ? colors.orange : colors.gray[500] }}> - {event.roi}% - - - - - - - ))} - - )} - - - {/* Pagination */} - {totalPages > 1 && ( - - setCurrentPage(page)} - size="large" + {/* Search Section */} + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} sx={{ - '& .MuiPaginationItem-root': { - color: colors.gray[700], - fontWeight: 600, - '&.Mui-selected': { - background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.blue} 100%)`, - color: 'white', - '&:hover': { - background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blueLight} 100%)`, - }, + '& .MuiOutlinedInput-root': { + borderRadius: 3, + bgcolor: 'white', + fontSize: { xs: '0.875rem', sm: '1rem' }, + '& fieldset': { + borderColor: colors.gray[200], }, - '&:hover': { - bgcolor: colors.gray[100], + '&:hover fieldset': { + borderColor: colors.gray[300], + }, + '&.Mui-focused fieldset': { + borderColor: colors.purple, }, }, }} /> - )} -
+ + {/* Filters */} + + + + 상태 + + + + 기간 + + + + + + {/* Sorting */} + + + + 정렬 + + + + + + + + {/* Event List */} + + {pageEvents.length === 0 ? ( + + + + + event_busy + + + + 검색 결과가 없습니다 + + + 다른 검색 조건으로 다시 시도해보세요 + + + + ) : ( + + {pageEvents.map((event) => ( + handleEventClick(event.id)} + > + + {/* Header with Badges */} + + + + {event.title} + + + {getStatusText(event.status)} + {event.status === 'active' + ? ` | D-${event.daysLeft}` + : event.status === 'scheduled' + ? ` | D+${event.daysLeft}` + : ''} + + + + {/* Status Badges */} + + {event.isUrgent && ( + } + label="마감임박" + size="small" + sx={{ + bgcolor: '#FEF3C7', + color: '#92400E', + fontWeight: 600, + fontSize: { xs: '0.6875rem', sm: '0.75rem' }, + height: { xs: 24, sm: 28 }, + '& .MuiChip-icon': { color: '#92400E' }, + }} + /> + )} + {event.isPopular && ( + } + label="인기" + size="small" + sx={{ + bgcolor: '#FEE2E2', + color: '#991B1B', + fontWeight: 600, + fontSize: { xs: '0.6875rem', sm: '0.75rem' }, + height: { xs: 24, sm: 28 }, + '& .MuiChip-icon': { color: '#991B1B' }, + }} + /> + )} + {event.isHighROI && ( + } + label="높은 ROI" + size="small" + sx={{ + bgcolor: '#DCFCE7', + color: '#166534', + fontWeight: 600, + fontSize: { xs: '0.6875rem', sm: '0.75rem' }, + height: { xs: 24, sm: 28 }, + '& .MuiChip-icon': { color: '#166534' }, + }} + /> + )} + {event.isNew && ( + } + label="신규" + size="small" + sx={{ + bgcolor: '#DBEAFE', + color: '#1E40AF', + fontWeight: 600, + fontSize: { xs: '0.6875rem', sm: '0.75rem' }, + height: { xs: 24, sm: 28 }, + '& .MuiChip-icon': { color: '#1E40AF' }, + }} + /> + )} + + + + {/* Progress Bar for Active Events */} + {event.status === 'active' && ( + + + + 이벤트 진행률 + + + {Math.round(calculateProgress(event))}% + + + + + )} + + {/* Event Info and Stats Container */} + + {/* Left: Event Info */} + + + + + + {event.prize} + + + + {getMethodIcon(event.method)} + + {event.method} + + + + + {/* Date */} + + 📅 + + {event.startDate} ~ {event.endDate} + + + + + {/* Right: Stats */} + + + + 참여자 + + + {event.participants.toLocaleString()} + + 명 + + + {event.targetParticipants > 0 && ( + + 목표: {event.targetParticipants}명 ( + {Math.round((event.participants / event.targetParticipants) * 100)} + %) + + )} + + + + ROI + + = 400 + ? colors.mint + : event.roi >= 200 + ? colors.orange + : colors.gray[500], + }} + > + {event.roi}% + + + + + + + ))} + + )} + + + {/* Pagination */} + {totalPages > 1 && ( + + setCurrentPage(page)} + size="large" + sx={{ + '& .MuiPaginationItem-root': { + color: colors.gray[700], + fontWeight: 600, + '&.Mui-selected': { + background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.blue} 100%)`, + color: 'white', + '&:hover': { + background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blueLight} 100%)`, + }, + }, + '&:hover': { + bgcolor: colors.gray[100], + }, + }, + }} + /> + + )} +
); diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index a6888e0..dc04e81 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -88,14 +88,14 @@ export default function HomePage() { minHeight: '100vh', }} > - + {/* Welcome Section */} - + 안녕하세요, {mockUser.name}님! 👋 @@ -106,7 +106,7 @@ export default function HomePage() { {/* KPI Cards */} - + - + @@ -139,10 +139,10 @@ export default function HomePage() { @@ -153,7 +153,7 @@ export default function HomePage() { sx={{ fontWeight: 700, color: colors.gray[900], - fontSize: '2.25rem', + fontSize: { xs: '1.5rem', sm: '2.25rem' }, textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > @@ -171,22 +171,22 @@ export default function HomePage() { borderColor: 'transparent', }} > - + @@ -194,10 +194,10 @@ export default function HomePage() { @@ -208,7 +208,7 @@ export default function HomePage() { sx={{ fontWeight: 700, color: colors.gray[900], - fontSize: '2.25rem', + fontSize: { xs: '1.5rem', sm: '2.25rem' }, textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > @@ -226,22 +226,22 @@ export default function HomePage() { borderColor: 'transparent', }} > - + @@ -249,10 +249,10 @@ export default function HomePage() { @@ -263,7 +263,7 @@ export default function HomePage() { sx={{ fontWeight: 700, color: colors.gray[900], - fontSize: '2.25rem', + fontSize: { xs: '1.5rem', sm: '2.25rem' }, textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > @@ -275,11 +275,11 @@ export default function HomePage() { {/* Quick Actions */} - - + + 빠른 시작 - + - + - + - + 새 이벤트 @@ -319,24 +319,24 @@ export default function HomePage() { }} onClick={handleViewAnalytics} > - + - + - + 성과분석 @@ -346,20 +346,21 @@ export default function HomePage() { {/* Active Events */} - + 진행 중인 이벤트 + + ); +} +``` + +### 3. 회원가입 구현 예제 + +```tsx +'use client'; + +import { useAuthContext } from '@/features/auth'; +import { useState } from 'react'; +import type { RegisterRequest } from '@/entities/user'; + +export default function RegisterPage() { + const { register, isLoading } = useAuthContext(); + const [formData, setFormData] = useState({ + name: '', + phoneNumber: '', + email: '', + password: '', + storeName: '', + industry: '', + address: '', + businessHours: '', + }); + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + + const result = await register(formData); + + if (result.success) { + console.log('회원가입 성공:', result.user); + } else { + console.error('회원가입 실패:', result.error); + } + }; + + return ( +
+ {/* 폼 필드들... */} + +
+ ); +} +``` + +### 4. 프로필 조회 및 수정 예제 + +```tsx +'use client'; + +import { useProfile } from '@/features/profile'; +import { useEffect } from 'react'; + +export default function ProfilePage() { + const { + profile, + isLoading, + error, + fetchProfile, + updateProfile + } = useProfile(); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + const handleUpdate = async () => { + const result = await updateProfile({ + name: '새로운 이름', + storeName: '새로운 가게명', + }); + + if (result.success) { + console.log('프로필 수정 성공:', result.data); + } + }; + + if (isLoading) return
로딩 중...
; + if (error) return
에러: {error}
; + if (!profile) return
프로필 없음
; + + return ( +
+

{profile.userName}

+

이메일: {profile.email}

+

가게명: {profile.storeName}

+ +
+ ); +} +``` + +### 5. 인증 상태 확인 예제 + +```tsx +'use client'; + +import { useAuthContext } from '@/features/auth'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +export default function ProtectedPage() { + const { user, isAuthenticated, isLoading } = useAuthContext(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) return
로딩 중...
; + if (!isAuthenticated) return null; + + return ( +
+

환영합니다, {user?.userName}님!

+
+ ); +} +``` + +### 6. 로그아웃 구현 예제 + +```tsx +'use client'; + +import { useAuthContext } from '@/features/auth'; +import { useRouter } from 'next/navigation'; + +export default function Header() { + const { user, isAuthenticated, logout } = useAuthContext(); + const router = useRouter(); + + const handleLogout = async () => { + await logout(); + router.push('/login'); + }; + + if (!isAuthenticated) return null; + + return ( +
+ {user?.userName} + +
+ ); +} +``` + +## 타입 정의 + +### User 타입 + +```typescript +interface User { + userId: number; + userName: string; + email: string; + role: string; + phoneNumber?: string; + storeId?: number; + storeName?: string; + industry?: string; + address?: string; + businessHours?: string; +} +``` + +### LoginRequest 타입 + +```typescript +interface LoginRequest { + email: string; + password: string; +} +``` + +### RegisterRequest 타입 + +```typescript +interface RegisterRequest { + name: string; + phoneNumber: string; // 패턴: ^010\d{8}$ + email: string; + password: string; // 최소 8자 + storeName: string; + industry?: string; + address: string; + businessHours?: string; +} +``` + +## API Client 설정 + +API 클라이언트는 다음 기능을 자동으로 처리합니다: + +1. **JWT 토큰 자동 추가**: localStorage의 `accessToken`을 자동으로 헤더에 포함 +2. **401 인증 오류 처리**: 인증 실패 시 자동으로 토큰 삭제 및 로그인 페이지로 리다이렉트 +3. **Base URL 설정**: 환경 변수로 API 서버 URL 관리 + +## 환경 변수 + +`.env.local` 파일에 다음 환경 변수를 설정하세요: + +```env +NEXT_PUBLIC_API_BASE_URL=http://20.196.65.160:8081 +``` + +## 주의사항 + +1. **토큰 관리**: 토큰은 localStorage에 저장되며, 로그아웃 시 자동으로 삭제됩니다. +2. **인증 상태**: AuthProvider로 감싼 컴포넌트에서만 useAuthContext 사용 가능합니다. +3. **에러 처리**: 모든 API 함수는 try-catch로 에러를 처리하며, 결과 객체에 success와 error를 포함합니다. +4. **비밀번호 검증**: 회원가입 시 비밀번호는 최소 8자 이상이어야 합니다. +5. **전화번호 형식**: 010으로 시작하는 11자리 숫자만 허용됩니다. + +## 빌드 및 실행 + +```bash +# 빌드 +npm run build + +# 개발 서버 실행 (사용자가 직접 수행) +npm run dev +``` diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index e835d8a..b31f125 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -19,7 +19,7 @@ import { IconButton, } from '@mui/material'; import { Visibility, VisibilityOff, Email, Lock, ChatBubble } from '@mui/icons-material'; -import { useAuthStore } from '@/stores/authStore'; +import { useAuthContext } from '@/features/auth'; import { useUIStore } from '@/stores/uiStore'; import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles'; @@ -31,8 +31,7 @@ const loginSchema = z.object({ .email('올바른 이메일 형식이 아닙니다'), password: z .string() - .min(8, '비밀번호는 8자 이상이어야 합니다') - .regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'), + .min(1, '비밀번호를 입력해주세요'), rememberMe: z.boolean().optional(), }); @@ -40,7 +39,7 @@ type LoginFormData = z.infer; export default function LoginPage() { const router = useRouter(); - const { login } = useAuthStore(); + const { login } = useAuthContext(); const { showToast, setLoading } = useUIStore(); const [showPassword, setShowPassword] = useState(false); @@ -58,32 +57,28 @@ export default function LoginPage() { }); const onSubmit = async (data: LoginFormData) => { + console.log('🔐 로그인 시도:', { email: data.email }); + try { setLoading(true); - // TODO: API 연동 시 실제 로그인 처리 - // const response = await axios.post(`${USER_HOST}/api/v1/auth/login`, { - // email: data.email, - // password: data.password, - // }); - - // 임시 로그인 처리 (API 연동 전) - await new Promise(resolve => setTimeout(resolve, 1000)); - - const mockUser = { - id: '1', - name: '홍길동', - phone: '010-1234-5678', + // User API 호출 + const result = await login({ email: data.email, - businessName: '홍길동 고깃집', - businessType: 'restaurant', - }; + password: data.password, + }); - login(mockUser, 'mock-jwt-token'); - showToast('로그인되었습니다', 'success'); - router.push('/'); - } catch { - showToast('로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', 'error'); + if (result.success) { + console.log('✅ 로그인 성공:', result.user); + showToast('로그인되었습니다', 'success'); + router.push('/'); + } else { + console.error('❌ 로그인 실패:', result.error); + showToast(result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', 'error'); + } + } catch (error) { + console.error('💥 로그인 예외:', error); + showToast('로그인 중 오류가 발생했습니다. 다시 시도해주세요.', 'error'); } finally { setLoading(false); } diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index fd53429..2cecee3 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -26,18 +26,18 @@ import { import { ArrowBack, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material'; import { useState, useEffect, Suspense } from 'react'; import { useUIStore } from '@/stores/uiStore'; -import { useAuthStore } from '@/stores/authStore'; +import { useAuthContext } from '@/features/auth'; import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles'; // 각 단계별 유효성 검사 스키마 const step1Schema = z .object({ - email: z.string().email('올바른 이메일 형식이 아닙니다'), + email: z.string().min(1, '이메일을 입력해주세요').email('올바른 이메일 형식이 아닙니다'), password: z .string() .min(8, '비밀번호는 8자 이상이어야 합니다') - .regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'), - confirmPassword: z.string(), + .max(100, '비밀번호는 100자 이하여야 합니다'), + confirmPassword: z.string().min(1, '비밀번호 확인을 입력해주세요'), }) .refine((data) => data.password === data.confirmPassword, { message: '비밀번호가 일치하지 않습니다', @@ -45,7 +45,7 @@ const step1Schema = z }); const step2Schema = z.object({ - name: z.string().min(2, '이름은 2자 이상이어야 합니다'), + name: z.string().min(2, '이름은 2자 이상이어야 합니다').max(50, '이름은 50자 이하여야 합니다'), phone: z .string() .min(1, '휴대폰 번호를 입력해주세요') @@ -53,13 +53,14 @@ const step2Schema = z.object({ }); const step3Schema = z.object({ - businessName: z.string().min(2, '상호명은 2자 이상이어야 합니다'), + businessName: z.string().min(2, '상호명은 2자 이상이어야 합니다').max(100, '상호명은 100자 이하여야 합니다'), businessNumber: z .string() .min(1, '사업자 번호를 입력해주세요') .regex(/^\d{3}-\d{2}-\d{5}$/, '올바른 사업자 번호 형식이 아닙니다 (123-45-67890)'), - businessType: z.string().min(1, '업종을 선택해주세요'), - businessLocation: z.string().optional(), + businessType: z.string().min(1, '업종을 선택해주세요').max(50, '업종은 50자 이하여야 합니다'), + businessLocation: z.string().max(255, '주소는 255자 이하여야 합니다').optional(), + businessHours: z.string().max(255, '영업시간은 255자 이하여야 합니다').optional(), agreeTerms: z.boolean().refine((val) => val === true, { message: '이용약관에 동의해주세요', }), @@ -79,7 +80,7 @@ function RegisterForm() { const router = useRouter(); const searchParams = useSearchParams(); const { showToast, setLoading } = useUIStore(); - const { login } = useAuthStore(); + const { register: registerUser } = useAuthContext(); // URL 쿼리에서 step 파라미터 읽기 (기본값: 1) const stepParam = searchParams.get('step'); @@ -206,33 +207,62 @@ function RegisterForm() { }; const handleSubmit = async () => { + console.log('📝 Step 3 검증 시작'); + if (!validateStep(3)) { + console.error('❌ Step 3 검증 실패'); return; } + console.log('✅ Step 3 검증 통과'); + try { setLoading(true); + console.log('🔄 회원가입 프로세스 시작'); - // TODO: API 연동 시 실제 회원가입 처리 - // const response = await axios.post(`${USER_HOST}/api/v1/auth/register`, formData); + // 전화번호 형식 변환: 010-1234-5678 -> 01012345678 + const phoneNumber = formData.phone!.replace(/-/g, ''); + console.log('📞 전화번호 변환:', formData.phone, '->', phoneNumber); - // 임시 처리 - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const mockUser = { - id: '1', + // API 요청 데이터 구성 + const registerData = { name: formData.name!, - phone: formData.phone!, + phoneNumber: phoneNumber, email: formData.email!, - businessName: formData.businessName!, - businessType: formData.businessType!, + password: formData.password!, + storeName: formData.businessName!, + industry: formData.businessType || '', + address: formData.businessLocation || '', + businessHours: formData.businessHours || '', }; - login(mockUser, 'mock-jwt-token'); - setSuccessDialogOpen(true); - } catch { - showToast('회원가입에 실패했습니다. 다시 시도해주세요.', 'error'); + console.log('📦 회원가입 요청 데이터:', { + ...registerData, + password: '***' // 비밀번호는 로그에 표시 안 함 + }); + + // User API 호출 + console.log('🚀 registerUser 함수 호출'); + const result = await registerUser(registerData); + console.log('📥 registerUser 결과:', result); + + if (result.success) { + console.log('✅ 회원가입 성공:', result.user); + showToast('회원가입이 완료되었습니다!', 'success'); + setSuccessDialogOpen(true); + } else { + console.error('❌ 회원가입 실패:', result.error); + showToast(result.error || '회원가입에 실패했습니다. 다시 시도해주세요.', 'error'); + } + } catch (error) { + console.error('💥 회원가입 예외 발생:', error); + if (error instanceof Error) { + console.error('오류 메시지:', error.message); + console.error('오류 스택:', error.stack); + } + showToast('회원가입 중 오류가 발생했습니다. 다시 시도해주세요.', 'error'); } finally { + console.log('🏁 회원가입 프로세스 종료'); setLoading(false); } }; @@ -314,11 +344,11 @@ function RegisterForm() { fullWidth label="비밀번호" type={showPassword ? 'text' : 'password'} - placeholder="8자 이상, 영문+숫자 조합" + placeholder="8자 이상" value={formData.password || ''} onChange={(e) => setFormData({ ...formData, password: e.target.value })} error={!!errors.password} - helperText={errors.password} + helperText={errors.password || '비밀번호는 8자 이상이어야 합니다'} required InputProps={{ endAdornment: ( @@ -548,10 +578,19 @@ function RegisterForm() { setFormData({ ...formData, businessLocation: e.target.value })} + helperText="사업장 주소를 입력해주세요" + /> + + setFormData({ ...formData, businessHours: e.target.value })} helperText="선택 사항입니다" /> diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx index b970fd5..8030e7d 100644 --- a/src/app/(main)/profile/page.tsx +++ b/src/app/(main)/profile/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -26,8 +26,9 @@ import { DialogActions, } from '@mui/material'; import { Person, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material'; -import { useAuthStore } from '@/stores/authStore'; +import { useAuthContext } from '@/features/auth'; import { useUIStore } from '@/stores/uiStore'; +import { userApi } from '@/entities/user'; import Header from '@/shared/ui/Header'; import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles'; @@ -56,7 +57,7 @@ const passwordSchema = z newPassword: z .string() .min(8, '비밀번호는 8자 이상이어야 합니다') - .regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'), + .max(100, '비밀번호는 100자 이하여야 합니다'), confirmPassword: z.string(), }) .refine((data) => data.newPassword === data.confirmPassword, { @@ -70,25 +71,27 @@ type PasswordData = z.infer; export default function ProfilePage() { const router = useRouter(); - const { user, logout, setUser } = useAuthStore(); + const { user, logout, refreshProfile } = useAuthContext(); const { showToast, setLoading } = useUIStore(); const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [successDialogOpen, setSuccessDialogOpen] = useState(false); const [logoutDialogOpen, setLogoutDialogOpen] = useState(false); + const [profileLoaded, setProfileLoaded] = useState(false); // 기본 정보 폼 const { control: basicControl, handleSubmit: handleBasicSubmit, formState: { errors: basicErrors }, + reset: resetBasic, } = useForm({ resolver: zodResolver(basicInfoSchema), defaultValues: { - name: user?.name || '', - phone: user?.phone || '', - email: user?.email || '', + name: '', + phone: '', + email: '', }, }); @@ -97,11 +100,12 @@ export default function ProfilePage() { control: businessControl, handleSubmit: handleBusinessSubmit, formState: { errors: businessErrors }, + reset: resetBusiness, } = useForm({ resolver: zodResolver(businessInfoSchema), defaultValues: { - businessName: user?.businessName || '', - businessType: user?.businessType || '', + businessName: '', + businessType: '', businessLocation: '', businessHours: '', }, @@ -122,6 +126,68 @@ export default function ProfilePage() { }, }); + // 프로필 데이터 로드 + useEffect(() => { + const loadProfile = async () => { + console.log('📋 프로필 페이지: 프로필 데이터 로드 시작'); + + if (!user) { + console.log('❌ 사용자 정보 없음, 로그인 페이지로 이동'); + router.push('/login'); + return; + } + + if (profileLoaded) { + console.log('✅ 프로필 이미 로드됨'); + return; + } + + try { + setLoading(true); + console.log('📡 프로필 조회 API 호출'); + + const profile = await userApi.getProfile(); + console.log('📥 프로필 조회 성공:', profile); + + // 전화번호 형식 변환: 01012345678 → 010-1234-5678 + const formattedPhone = profile.phoneNumber + ? `${profile.phoneNumber.slice(0, 3)}-${profile.phoneNumber.slice(3, 7)}-${profile.phoneNumber.slice(7, 11)}` + : ''; + + // 기본 정보 폼 초기화 + resetBasic({ + name: profile.userName || '', + phone: formattedPhone, + email: profile.email || '', + }); + + // 사업장 정보 폼 초기화 + resetBusiness({ + businessName: profile.storeName || '', + businessType: profile.industry || '', + businessLocation: profile.address || '', + businessHours: profile.businessHours || '', + }); + + setProfileLoaded(true); + console.log('✅ 프로필 폼 초기화 완료'); + } catch (error: any) { + console.error('❌ 프로필 로드 실패:', error); + + if (error.response?.status === 401) { + showToast('로그인이 필요합니다', 'error'); + router.push('/login'); + } else { + showToast('프로필 정보를 불러오는데 실패했습니다', 'error'); + } + } finally { + setLoading(false); + } + }; + + loadProfile(); + }, [user, profileLoaded, router, resetBasic, resetBusiness, setLoading, showToast]); + const formatPhoneNumber = (value: string) => { const numbers = value.replace(/[^\d]/g, ''); if (numbers.length <= 3) return numbers; @@ -130,46 +196,84 @@ export default function ProfilePage() { }; const onSaveProfile = async (data: BasicInfoData & BusinessInfoData) => { + console.log('💾 프로필 저장 시작'); + console.log('📦 저장 데이터:', { ...data, phone: data.phone }); + try { setLoading(true); - // TODO: API 연동 시 실제 프로필 업데이트 - // await axios.put(`${USER_HOST}/api/v1/users/profile`, data); + // 전화번호 형식 변환: 010-1234-5678 → 01012345678 + const phoneNumber = data.phone.replace(/-/g, ''); + console.log('📞 전화번호 변환:', data.phone, '->', phoneNumber); - await new Promise(resolve => setTimeout(resolve, 1000)); + const updateData = { + userName: data.name, + phoneNumber: phoneNumber, + storeName: data.businessName, + industry: data.businessType, + address: data.businessLocation || '', + businessHours: data.businessHours || '', + }; - if (user) { - setUser({ - ...user, - ...data, - }); - } + console.log('📡 프로필 업데이트 API 호출:', updateData); + await userApi.updateProfile(updateData); + console.log('✅ 프로필 업데이트 성공'); + + // 최신 프로필 정보 다시 가져오기 + console.log('🔄 프로필 새로고침'); + await refreshProfile(); + console.log('✅ 프로필 새로고침 완료'); setSuccessDialogOpen(true); - } catch { - showToast('프로필 저장에 실패했습니다', 'error'); + showToast('프로필이 저장되었습니다', 'success'); + } catch (error: any) { + console.error('❌ 프로필 저장 실패:', error); + + let errorMessage = '프로필 저장에 실패했습니다'; + if (error.response) { + errorMessage = error.response.data?.message || + error.response.data?.error || + `서버 오류 (${error.response.status})`; + } else if (error.request) { + errorMessage = '서버로부터 응답이 없습니다'; + } + + showToast(errorMessage, 'error'); } finally { setLoading(false); } }; const onChangePassword = async (data: PasswordData) => { - console.log('Password change data:', data); + console.log('🔐 비밀번호 변경 시작'); + try { setLoading(true); - // TODO: API 연동 시 실제 비밀번호 변경 - // await axios.put(`${USER_HOST}/api/v1/users/password`, { - // currentPassword: _data.currentPassword, - // newPassword: _data.newPassword, - // }); + const passwordData = { + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }; - await new Promise(resolve => setTimeout(resolve, 1000)); + console.log('📡 비밀번호 변경 API 호출'); + await userApi.changePassword(passwordData); + console.log('✅ 비밀번호 변경 성공'); showToast('비밀번호가 변경되었습니다', 'success'); resetPassword(); - } catch { - showToast('비밀번호 변경에 실패했습니다', 'error'); + } catch (error: any) { + console.error('❌ 비밀번호 변경 실패:', error); + + let errorMessage = '비밀번호 변경에 실패했습니다'; + if (error.response) { + errorMessage = error.response.data?.message || + error.response.data?.error || + `서버 오류 (${error.response.status})`; + } else if (error.request) { + errorMessage = '서버로부터 응답이 없습니다'; + } + + showToast(errorMessage, 'error'); } finally { setLoading(false); } @@ -183,9 +287,21 @@ export default function ProfilePage() { })(); }; - const handleLogout = () => { - logout(); - router.push('/login'); + const handleLogout = async () => { + console.log('🚪 로그아웃 시작'); + setLoading(true); + + try { + await logout(); + showToast('로그아웃되었습니다', 'success'); + } catch (error) { + console.error('❌ 로그아웃 중 예상치 못한 에러:', error); + showToast('로그아웃되었습니다', 'success'); + } finally { + setLoading(false); + // 로그아웃은 항상 로그인 페이지로 이동 + router.push('/login'); + } }; return ( @@ -216,7 +332,7 @@ export default function ProfilePage() { - {user?.name} + {user?.userName} {user?.email} @@ -400,7 +516,7 @@ export default function ProfilePage() { label="새 비밀번호" placeholder="새 비밀번호를 입력하세요" error={!!passwordErrors.newPassword} - helperText={passwordErrors.newPassword?.message || '8자 이상, 영문과 숫자를 포함해주세요'} + helperText={passwordErrors.newPassword?.message || '8자 이상 입력해주세요'} InputProps={{ endAdornment: ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 65d3c18..37badca 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next'; import { MUIThemeProvider } from '@/shared/lib/theme-provider'; import { ReactQueryProvider } from '@/shared/lib/react-query-provider'; +import { AuthProvider } from '@/features/auth'; import '@/styles/globals.css'; export const metadata: Metadata = { @@ -35,7 +36,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/src/entities/user/api/userApi.ts b/src/entities/user/api/userApi.ts new file mode 100644 index 0000000..642ae39 --- /dev/null +++ b/src/entities/user/api/userApi.ts @@ -0,0 +1,103 @@ +import { apiClient } from '@/shared/api'; +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + LogoutResponse, + ProfileResponse, + UpdateProfileRequest, + ChangePasswordRequest, +} from '../model/types'; + +const USER_API_BASE = '/api/v1/users'; + +/** + * User API Service + * 사용자 인증 및 프로필 관리 API + */ +export const userApi = { + /** + * 로그인 + */ + login: async (data: LoginRequest): Promise => { + const response = await apiClient.post( + `${USER_API_BASE}/login`, + data + ); + return response.data; + }, + + /** + * 회원가입 + */ + register: async (data: RegisterRequest): Promise => { + console.log('📞 userApi.register 호출'); + console.log('🎯 URL:', `${USER_API_BASE}/register`); + console.log('📦 요청 데이터:', { + ...data, + password: '***' + }); + + try { + const response = await apiClient.post( + `${USER_API_BASE}/register`, + data + ); + console.log('✅ userApi.register 성공:', response.data); + return response.data; + } catch (error) { + console.error('❌ userApi.register 실패:', error); + throw error; + } + }, + + /** + * 로그아웃 + */ + logout: async (): Promise => { + const token = localStorage.getItem('accessToken'); + const response = await apiClient.post( + `${USER_API_BASE}/logout`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data; + }, + + /** + * 프로필 조회 + */ + getProfile: async (): Promise => { + const response = await apiClient.get( + `${USER_API_BASE}/profile` + ); + return response.data; + }, + + /** + * 프로필 수정 + */ + updateProfile: async ( + data: UpdateProfileRequest + ): Promise => { + const response = await apiClient.put( + `${USER_API_BASE}/profile`, + data + ); + return response.data; + }, + + /** + * 비밀번호 변경 + */ + changePassword: async (data: ChangePasswordRequest): Promise => { + await apiClient.put(`${USER_API_BASE}/password`, data); + }, +}; + +export default userApi; diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts new file mode 100644 index 0000000..abf6b40 --- /dev/null +++ b/src/entities/user/index.ts @@ -0,0 +1,16 @@ +// Types +export type { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + LogoutResponse, + ProfileResponse, + UpdateProfileRequest, + ChangePasswordRequest, + User, + AuthState, +} from './model/types'; + +// API +export { userApi } from './api/userApi'; diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts new file mode 100644 index 0000000..c8b933f --- /dev/null +++ b/src/entities/user/model/types.ts @@ -0,0 +1,98 @@ +/** + * User Entity Types + * API 스펙 기반 타입 정의 + */ + +// 로그인 요청/응답 +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + token: string; + userId: number; + userName: string; + role: string; + email: string; +} + +// 회원가입 요청/응답 +export interface RegisterRequest { + name: string; + phoneNumber: string; + email: string; + password: string; + storeName: string; + industry?: string; + address: string; + businessHours?: string; +} + +export interface RegisterResponse { + token: string; + userId: number; + userName: string; + storeId: number; + storeName: string; +} + +// 로그아웃 응답 +export interface LogoutResponse { + success: boolean; + message: string; +} + +// 프로필 조회/수정 +export interface ProfileResponse { + userId: number; + userName: string; + phoneNumber: string; + email: string; + role: string; + storeId: number; + storeName: string; + industry: string; + address: string; + businessHours: string; + createdAt: string; + lastLoginAt: string; +} + +export interface UpdateProfileRequest { + name?: string; + phoneNumber?: string; + email?: string; + storeName?: string; + industry?: string; + address?: string; + businessHours?: string; +} + +// 비밀번호 변경 +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +// User 상태 +export interface User { + userId: number; + userName: string; + email: string; + role: string; + phoneNumber?: string; + storeId?: number; + storeName?: string; + industry?: string; + address?: string; + businessHours?: string; +} + +// 인증 상태 +export interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; +} diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts new file mode 100644 index 0000000..a90ec8a --- /dev/null +++ b/src/features/auth/index.ts @@ -0,0 +1,2 @@ +export { useAuth } from './model/useAuth'; +export { AuthProvider, useAuthContext } from './model/AuthProvider'; diff --git a/src/features/auth/model/AuthProvider.tsx b/src/features/auth/model/AuthProvider.tsx new file mode 100644 index 0000000..870b1a7 --- /dev/null +++ b/src/features/auth/model/AuthProvider.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React, { createContext, useContext, ReactNode } from 'react'; +import { useAuth } from './useAuth'; +import type { AuthState, LoginRequest, RegisterRequest, User } from '@/entities/user'; + +interface AuthContextType extends AuthState { + login: (credentials: LoginRequest) => Promise<{ + success: boolean; + user?: User; + error?: string; + }>; + register: (data: RegisterRequest) => Promise<{ + success: boolean; + user?: User; + error?: string; + }>; + logout: () => Promise; + refreshProfile: () => Promise<{ + success: boolean; + user?: User; + error?: string; + }>; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const auth = useAuth(); + + return {children}; +}; + +export const useAuthContext = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuthContext must be used within AuthProvider'); + } + return context; +}; diff --git a/src/features/auth/model/useAuth.ts b/src/features/auth/model/useAuth.ts new file mode 100644 index 0000000..5714b3f --- /dev/null +++ b/src/features/auth/model/useAuth.ts @@ -0,0 +1,219 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { userApi } from '@/entities/user'; +import type { + LoginRequest, + RegisterRequest, + User, + AuthState, +} from '@/entities/user'; + +const TOKEN_KEY = 'accessToken'; +const USER_KEY = 'user'; + +/** + * 인증 관련 커스텀 훅 + */ +export const useAuth = () => { + const [authState, setAuthState] = useState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: true, + }); + + // 초기 인증 상태 확인 + useEffect(() => { + const token = localStorage.getItem(TOKEN_KEY); + const userStr = localStorage.getItem(USER_KEY); + + if (token && userStr) { + try { + const user = JSON.parse(userStr) as User; + setAuthState({ + user, + token, + isAuthenticated: true, + isLoading: false, + }); + } catch { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }); + } + } else { + setAuthState((prev) => ({ ...prev, isLoading: false })); + } + }, []); + + // 로그인 + const login = useCallback(async (credentials: LoginRequest) => { + try { + const response = await userApi.login(credentials); + + const user: User = { + userId: response.userId, + userName: response.userName, + email: response.email, + role: response.role, + }; + + localStorage.setItem(TOKEN_KEY, response.token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + + setAuthState({ + user, + token: response.token, + isAuthenticated: true, + isLoading: false, + }); + + return { success: true, user }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '로그인에 실패했습니다.', + }; + } + }, []); + + // 회원가입 + const register = useCallback(async (data: RegisterRequest) => { + console.log('🔐 useAuth.register 시작'); + console.log('📋 회원가입 데이터:', { + ...data, + password: '***' + }); + + try { + console.log('📡 userApi.register 호출'); + const response = await userApi.register(data); + console.log('📨 userApi.register 응답:', response); + + const user: User = { + userId: response.userId, + userName: response.userName, + email: data.email, + role: 'USER', + storeId: response.storeId, + storeName: response.storeName, + }; + + console.log('👤 생성된 User 객체:', user); + + localStorage.setItem(TOKEN_KEY, response.token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + console.log('💾 localStorage에 토큰과 사용자 정보 저장 완료'); + + setAuthState({ + user, + token: response.token, + isAuthenticated: true, + isLoading: false, + }); + console.log('✅ 인증 상태 업데이트 완료'); + + return { success: true, user }; + } catch (error: any) { + console.error('❌ useAuth.register 에러:', error); + + let errorMessage = '회원가입에 실패했습니다.'; + + if (error.response) { + // 서버가 응답을 반환한 경우 + console.error('서버 응답 에러:', { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data, + }); + errorMessage = error.response.data?.message || + error.response.data?.error || + `서버 오류 (${error.response.status})`; + } else if (error.request) { + // 요청은 보냈지만 응답을 받지 못한 경우 + console.error('응답 없음:', error.request); + errorMessage = '서버로부터 응답이 없습니다. 네트워크 연결을 확인해주세요.'; + } else { + // 요청 설정 중 에러 발생 + console.error('요청 설정 에러:', error.message); + errorMessage = error.message; + } + + return { + success: false, + error: errorMessage, + }; + } + }, []); + + // 로그아웃 + const logout = useCallback(async () => { + try { + console.log('📡 로그아웃 API 호출'); + await userApi.logout(); + console.log('✅ 로그아웃 API 성공'); + } catch (error: any) { + console.warn('⚠️ 로그아웃 API 실패 (서버 에러):', { + status: error.response?.status, + message: error.response?.data?.message || error.message, + }); + console.log('ℹ️ 로컬 상태는 정리하고 로그아웃 처리를 계속합니다'); + } finally { + console.log('🧹 로컬 토큰 및 사용자 정보 삭제'); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + + setAuthState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }); + console.log('✅ 로그아웃 완료 (로컬 상태 정리됨)'); + } + }, []); + + // 프로필 새로고침 + const refreshProfile = useCallback(async () => { + try { + const profile = await userApi.getProfile(); + + const user: User = { + userId: profile.userId, + userName: profile.userName, + email: profile.email, + role: profile.role, + phoneNumber: profile.phoneNumber, + storeId: profile.storeId, + storeName: profile.storeName, + industry: profile.industry, + address: profile.address, + businessHours: profile.businessHours, + }; + + localStorage.setItem(USER_KEY, JSON.stringify(user)); + setAuthState((prev) => ({ ...prev, user })); + + return { success: true, user }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '프로필 조회에 실패했습니다.', + }; + } + }, []); + + return { + ...authState, + login, + register, + logout, + refreshProfile, + }; +}; diff --git a/src/features/profile/index.ts b/src/features/profile/index.ts new file mode 100644 index 0000000..c1fbffc --- /dev/null +++ b/src/features/profile/index.ts @@ -0,0 +1 @@ +export { useProfile } from './model/useProfile'; diff --git a/src/features/profile/model/useProfile.ts b/src/features/profile/model/useProfile.ts new file mode 100644 index 0000000..fe7f67c --- /dev/null +++ b/src/features/profile/model/useProfile.ts @@ -0,0 +1,80 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { userApi } from '@/entities/user'; +import type { + ProfileResponse, + UpdateProfileRequest, + ChangePasswordRequest, +} from '@/entities/user'; + +/** + * 프로필 관련 커스텀 훅 + */ +export const useProfile = () => { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 프로필 조회 + const fetchProfile = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await userApi.getProfile(); + setProfile(data); + return { success: true, data }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : '프로필 조회에 실패했습니다.'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsLoading(false); + } + }, []); + + // 프로필 수정 + const updateProfile = useCallback(async (data: UpdateProfileRequest) => { + setIsLoading(true); + setError(null); + try { + const updatedProfile = await userApi.updateProfile(data); + setProfile(updatedProfile); + return { success: true, data: updatedProfile }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : '프로필 수정에 실패했습니다.'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsLoading(false); + } + }, []); + + // 비밀번호 변경 + const changePassword = useCallback(async (data: ChangePasswordRequest) => { + setIsLoading(true); + setError(null); + try { + await userApi.changePassword(data); + return { success: true }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : '비밀번호 변경에 실패했습니다.'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsLoading(false); + } + }, []); + + return { + profile, + isLoading, + error, + fetchProfile, + updateProfile, + changePassword, + }; +}; diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts new file mode 100644 index 0000000..16aefe9 --- /dev/null +++ b/src/shared/api/client.ts @@ -0,0 +1,67 @@ +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://20.196.65.160:8081'; + +export const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: 90000, // 30초로 증가 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - JWT 토큰 추가 +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + console.log('🚀 API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + data: config.data, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 Token added to request'); + } + return config; + }, + (error: AxiosError) => { + console.error('❌ Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor - 에러 처리 +apiClient.interceptors.response.use( + (response) => { + console.log('✅ API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error: AxiosError) => { + console.error('❌ API Error:', { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + data: error.response?.data, + }); + + if (error.response?.status === 401) { + console.warn('🔒 401 Unauthorized - Redirecting to login'); + // 인증 실패 시 토큰 삭제 및 로그인 페이지로 리다이렉트 + localStorage.removeItem('accessToken'); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 0000000..51e397f --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1,2 @@ +export { apiClient } from './client'; +export type { ApiError } from './types'; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts new file mode 100644 index 0000000..d260f1d --- /dev/null +++ b/src/shared/api/types.ts @@ -0,0 +1,11 @@ +export interface ApiError { + message: string; + status: number; + code?: string; +} + +export interface ApiResponse { + data: T; + message?: string; + success?: boolean; +}