feat: 관리자 패널 방문자 분석 페이지 추가 (GA4 Data API 연동)
- /admin/analytics 페이지 신규 추가 - 일별 방문자 추이 바 차트 (7일/30일/90일 전환) - 오늘/이번주/기간별 요약 카드 - 유입 경로 (채널별 비율 바) - 기기 유형 분포 (PC/모바일/태블릿) - 상위 페이지 조회수 - GET /api/admin/analytics 라우트 신규 추가 (@google-analytics/data) - 사이드바에 방문자 분석 메뉴 추가 - 카페24 리뉴얼 견적 비교 SVG 에셋 추가 (public/marketing/quote-cafe24.svg) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
353
app/admin/analytics/page.tsx
Normal file
353
app/admin/analytics/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
type RangeKey = '7' | '30' | '90';
|
||||
|
||||
interface Summary {
|
||||
users: number;
|
||||
sessions: number;
|
||||
pageviews: number;
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
summary: {
|
||||
today: Summary;
|
||||
yesterday: Summary;
|
||||
week: Summary;
|
||||
period: Summary;
|
||||
};
|
||||
daily: Array<{ date: string; users: number; sessions: number; pageviews: number }>;
|
||||
topPages: Array<{ page: string; views: number; users: number }>;
|
||||
sources: Array<{ channel: string; sessions: number }>;
|
||||
devices: Array<{ device: string; sessions: number }>;
|
||||
}
|
||||
|
||||
const RANGE_LABELS: Record<RangeKey, string> = {
|
||||
'7': '최근 7일',
|
||||
'30': '최근 30일',
|
||||
'90': '최근 90일',
|
||||
};
|
||||
|
||||
const CHANNEL_KO: Record<string, string> = {
|
||||
'Organic Search': '검색 (유기)',
|
||||
'Direct': '직접 방문',
|
||||
'Organic Social': '소셜 미디어',
|
||||
'Referral': '외부 링크',
|
||||
'Paid Search': '검색 광고',
|
||||
'Email': '이메일',
|
||||
'Unassigned': '미분류',
|
||||
};
|
||||
|
||||
const DEVICE_KO: Record<string, string> = {
|
||||
'desktop': 'PC',
|
||||
'mobile': '모바일',
|
||||
'tablet': '태블릿',
|
||||
};
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
'Organic Search': '#22c55e',
|
||||
'Direct': '#3b82f6',
|
||||
'Organic Social': '#a855f7',
|
||||
'Referral': '#f59e0b',
|
||||
'Paid Search': '#ef4444',
|
||||
'Email': '#06b6d4',
|
||||
'Unassigned': '#64748b',
|
||||
};
|
||||
|
||||
function fmt(n: number) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function fmtDate(yyyymmdd: string) {
|
||||
const m = yyyymmdd.slice(4, 6);
|
||||
const d = yyyymmdd.slice(6, 8);
|
||||
return `${parseInt(m)}/${parseInt(d)}`;
|
||||
}
|
||||
|
||||
// 인라인 막대 차트
|
||||
function BarChart({ data }: { data: AnalyticsData['daily'] }) {
|
||||
const max = Math.max(...data.map((d) => d.users), 1);
|
||||
const w = 600;
|
||||
const h = 160;
|
||||
const padL = 36;
|
||||
const padR = 12;
|
||||
const padT = 12;
|
||||
const padB = 32;
|
||||
const chartW = w - padL - padR;
|
||||
const chartH = h - padT - padB;
|
||||
const barW = Math.max(2, chartW / data.length - 2);
|
||||
|
||||
// Y축 눈금
|
||||
const ticks = [0, Math.ceil(max / 2), max];
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full" style={{ height: 160 }}>
|
||||
{/* Y 눈금선 */}
|
||||
{ticks.map((t) => {
|
||||
const y = padT + chartH - (t / max) * chartH;
|
||||
return (
|
||||
<g key={t}>
|
||||
<line x1={padL} y1={y} x2={w - padR} y2={y} stroke="#1e293b" strokeWidth={1} />
|
||||
<text x={padL - 4} y={y + 4} textAnchor="end" fontSize={10} fill="#475569">
|
||||
{fmt(t)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 막대 + X 레이블 */}
|
||||
{data.map((d, i) => {
|
||||
const x = padL + (i / data.length) * chartW + (chartW / data.length - barW) / 2;
|
||||
const barH = Math.max(2, (d.users / max) * chartH);
|
||||
const y = padT + chartH - barH;
|
||||
const showLabel = data.length <= 14 || i % Math.ceil(data.length / 10) === 0;
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<rect x={x} y={y} width={barW} height={barH} rx={2} fill="#3b82f6" opacity={0.85} />
|
||||
{showLabel && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={h - 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill="#475569"
|
||||
>
|
||||
{fmtDate(d.date)}
|
||||
</text>
|
||||
)}
|
||||
<title>{`${fmtDate(d.date)}: ${d.users}명`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 유입 경로 가로 바
|
||||
function SourceBar({ channel, sessions, total }: { channel: string; sessions: number; total: number }) {
|
||||
const pct = total > 0 ? (sessions / total) * 100 : 0;
|
||||
const color = CHANNEL_COLORS[channel] ?? '#64748b';
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-28 text-xs text-slate-400 truncate flex-shrink-0">
|
||||
{CHANNEL_KO[channel] ?? channel}
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-700"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 w-12 text-right flex-shrink-0">{sessions.toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-500 w-10 text-right flex-shrink-0">{pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label, value, sub, trend,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
sub?: string;
|
||||
trend?: number; // 양수: 증가, 음수: 감소
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-slate-800/60 border border-slate-700/50 rounded-xl p-4">
|
||||
<p className="text-slate-400 text-xs font-medium mb-1">{label}</p>
|
||||
<p className="text-white text-2xl font-bold">{value.toLocaleString()}</p>
|
||||
{sub && <p className="text-slate-500 text-xs mt-0.5">{sub}</p>}
|
||||
{trend !== undefined && (
|
||||
<p className={`text-xs mt-1 font-medium ${trend >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{trend >= 0 ? '▲' : '▼'} {Math.abs(trend).toFixed(0)}% vs 어제
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [range, setRange] = useState<RangeKey>('30');
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (r: RangeKey) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/analytics?range=${r}`);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? '데이터 로드 실패');
|
||||
setData(json);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(range);
|
||||
}, [range, load]);
|
||||
|
||||
const todayTrend =
|
||||
data && data.summary.yesterday.users > 0
|
||||
? ((data.summary.today.users - data.summary.yesterday.users) / data.summary.yesterday.users) * 100
|
||||
: undefined;
|
||||
|
||||
const totalSessions = data?.sources.reduce((s, c) => s + c.sessions, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-bold">방문자 분석</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">Google Analytics 4 데이터</p>
|
||||
</div>
|
||||
{/* 기간 선택 */}
|
||||
<div className="flex gap-1 bg-slate-800 rounded-lg p-1">
|
||||
{(Object.keys(RANGE_LABELS) as RangeKey[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||
range === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 / 설정 안내 */}
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-700/40 rounded-xl p-4 text-sm text-red-300 space-y-2">
|
||||
<p className="font-semibold">⚠ 데이터를 불러올 수 없습니다</p>
|
||||
<p>{error}</p>
|
||||
{(error.includes('GOOGLE_SERVICE_ACCOUNT_JSON') || error.includes('GA4_PROPERTY_ID')) && (
|
||||
<div className="mt-3 bg-slate-900/50 rounded-lg p-3 text-slate-300 space-y-1 text-xs font-mono">
|
||||
<p className="text-slate-400 font-sans font-normal mb-2">환경변수 설정 필요 (.env.local + Vercel):</p>
|
||||
<p>GOOGLE_SERVICE_ACCOUNT_JSON={서비스 계정 JSON 전체}</p>
|
||||
<p>GA4_PROPERTY_ID=숫자로된_속성ID</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="오늘 방문자"
|
||||
value={data.summary.today.users}
|
||||
sub={`세션 ${data.summary.today.sessions.toLocaleString()}`}
|
||||
trend={todayTrend}
|
||||
/>
|
||||
<StatCard
|
||||
label="이번 주 방문자"
|
||||
value={data.summary.week.users}
|
||||
sub={`페이지뷰 ${data.summary.week.pageviews.toLocaleString()}`}
|
||||
/>
|
||||
<StatCard
|
||||
label={`${RANGE_LABELS[range]} 방문자`}
|
||||
value={data.summary.period.users}
|
||||
sub={`세션 ${data.summary.period.sessions.toLocaleString()}`}
|
||||
/>
|
||||
<StatCard
|
||||
label={`${RANGE_LABELS[range]} 페이지뷰`}
|
||||
value={data.summary.period.pageviews}
|
||||
sub={`방문당 ${data.summary.period.users > 0 ? (data.summary.period.pageviews / data.summary.period.users).toFixed(1) : 0} 페이지`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 일별 추이 차트 */}
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-5">
|
||||
<h2 className="text-white font-semibold text-sm mb-4">
|
||||
일별 방문자 추이 <span className="text-slate-500 font-normal ml-1">(활성 사용자)</span>
|
||||
</h2>
|
||||
{data.daily.length > 0 ? (
|
||||
<BarChart data={data.daily} />
|
||||
) : (
|
||||
<p className="text-slate-500 text-sm text-center py-8">데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 유입 경로 */}
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-5">
|
||||
<h2 className="text-white font-semibold text-sm mb-4">유입 경로</h2>
|
||||
{data.sources.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.sources.slice(0, 7).map((s) => (
|
||||
<SourceBar
|
||||
key={s.channel}
|
||||
channel={s.channel}
|
||||
sessions={s.sessions}
|
||||
total={totalSessions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-sm text-center py-6">데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 기기 + 상위 페이지 */}
|
||||
<div className="space-y-4">
|
||||
{/* 기기 분포 */}
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-5">
|
||||
<h2 className="text-white font-semibold text-sm mb-3">기기 유형</h2>
|
||||
<div className="flex gap-3">
|
||||
{data.devices.map((d) => {
|
||||
const pct = totalSessions > 0 ? ((d.sessions / totalSessions) * 100).toFixed(0) : '0';
|
||||
const icons: Record<string, string> = { desktop: '🖥', mobile: '📱', tablet: '⬜' };
|
||||
return (
|
||||
<div key={d.device} className="flex-1 bg-slate-800/60 rounded-lg p-3 text-center">
|
||||
<p className="text-xl">{icons[d.device] ?? '?'}</p>
|
||||
<p className="text-white font-bold text-lg mt-1">{pct}%</p>
|
||||
<p className="text-slate-400 text-xs">{DEVICE_KO[d.device] ?? d.device}</p>
|
||||
<p className="text-slate-500 text-xs">{d.sessions.toLocaleString()} 세션</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상위 페이지 */}
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-5">
|
||||
<h2 className="text-white font-semibold text-sm mb-3">상위 페이지</h2>
|
||||
<div className="space-y-2">
|
||||
{data.topPages.slice(0, 6).map((p, i) => (
|
||||
<div key={p.page} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-600 w-4 text-right flex-shrink-0">{i + 1}</span>
|
||||
<span className="flex-1 text-slate-300 truncate font-mono text-xs">{p.page}</span>
|
||||
<span className="text-blue-400 text-xs flex-shrink-0">{p.views.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-600 text-xs text-right">
|
||||
Google Analytics 4 · 데이터 기준 최대 24~48시간 지연 가능
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,16 @@ const NAV_ITEMS = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/analytics',
|
||||
label: '방문자 분석',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface AdminSidebarProps {
|
||||
|
||||
@@ -83,6 +83,16 @@ const ASSETS = [
|
||||
service: '홈페이지 제작',
|
||||
price: '스타터 50만원~',
|
||||
},
|
||||
{
|
||||
file: '/marketing/quote-cafe24.svg',
|
||||
name: '카페24 리뉴얼 견적 비교표',
|
||||
desc: '3옵션 가격 비교 — 숨고 견적 발송용',
|
||||
size: '1200 × 700',
|
||||
platform: '숨고 견적',
|
||||
color: '#3b82f6',
|
||||
service: '커머스 개발',
|
||||
price: '150~450만원',
|
||||
},
|
||||
];
|
||||
|
||||
const CHECKLIST_ITEMS = {
|
||||
|
||||
137
app/api/admin/analytics/route.ts
Normal file
137
app/api/admin/analytics/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
import { BetaAnalyticsDataClient } from '@google-analytics/data';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function getClient() {
|
||||
const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
|
||||
if (!raw) throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON 환경변수가 설정되지 않았습니다.');
|
||||
const credentials = JSON.parse(raw);
|
||||
return new BetaAnalyticsDataClient({ credentials });
|
||||
}
|
||||
|
||||
function getPropertyId() {
|
||||
const id = process.env.GA4_PROPERTY_ID;
|
||||
if (!id) throw new Error('GA4_PROPERTY_ID 환경변수가 설정되지 않았습니다.');
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// 관리자 인증
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
if (!token || !verifyAdminTokenNode(token)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const range = searchParams.get('range') ?? '30'; // 7, 30, 90
|
||||
const days = parseInt(range);
|
||||
|
||||
try {
|
||||
const client = getClient();
|
||||
const propertyId = getPropertyId();
|
||||
|
||||
const startDate = `${days}daysAgo`;
|
||||
|
||||
// 병렬로 3개 리포트 요청
|
||||
const [trendRes, pagesRes, sourcesRes] = await Promise.all([
|
||||
// 1. 일별 방문자 추이
|
||||
client.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
dateRanges: [{ startDate, endDate: 'today' }],
|
||||
dimensions: [{ name: 'date' }],
|
||||
metrics: [{ name: 'activeUsers' }, { name: 'sessions' }, { name: 'screenPageViews' }],
|
||||
orderBys: [{ dimension: { dimensionName: 'date' }, desc: false }],
|
||||
}),
|
||||
|
||||
// 2. 상위 페이지
|
||||
client.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
dateRanges: [{ startDate, endDate: 'today' }],
|
||||
dimensions: [{ name: 'pagePath' }],
|
||||
metrics: [{ name: 'screenPageViews' }, { name: 'activeUsers' }],
|
||||
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
||||
limit: 10,
|
||||
}),
|
||||
|
||||
// 3. 유입 경로 + 기기
|
||||
client.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
dateRanges: [{ startDate, endDate: 'today' }],
|
||||
dimensions: [{ name: 'sessionDefaultChannelGroup' }, { name: 'deviceCategory' }],
|
||||
metrics: [{ name: 'sessions' }, { name: 'activeUsers' }],
|
||||
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
||||
limit: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
// 오늘 / 어제 / 이번 주 / 기간 합계
|
||||
const summaryRes = await client.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
dateRanges: [
|
||||
{ startDate: 'today', endDate: 'today' },
|
||||
{ startDate: 'yesterday', endDate: 'yesterday' },
|
||||
{ startDate: '7daysAgo', endDate: 'today' },
|
||||
{ startDate: startDate, endDate: 'today' },
|
||||
],
|
||||
metrics: [{ name: 'activeUsers' }, { name: 'sessions' }, { name: 'screenPageViews' }],
|
||||
});
|
||||
|
||||
// --- 파싱 ---
|
||||
const summary = {
|
||||
today: { users: 0, sessions: 0, pageviews: 0 },
|
||||
yesterday: { users: 0, sessions: 0, pageviews: 0 },
|
||||
week: { users: 0, sessions: 0, pageviews: 0 },
|
||||
period: { users: 0, sessions: 0, pageviews: 0 },
|
||||
};
|
||||
const keys = ['today', 'yesterday', 'week', 'period'] as const;
|
||||
summaryRes[0].rows?.forEach((row, i) => {
|
||||
const key = keys[i];
|
||||
if (key) {
|
||||
summary[key] = {
|
||||
users: parseInt(row.metricValues?.[0]?.value ?? '0'),
|
||||
sessions: parseInt(row.metricValues?.[1]?.value ?? '0'),
|
||||
pageviews: parseInt(row.metricValues?.[2]?.value ?? '0'),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const daily = (trendRes[0].rows ?? []).map((row) => ({
|
||||
date: row.dimensionValues?.[0]?.value ?? '',
|
||||
users: parseInt(row.metricValues?.[0]?.value ?? '0'),
|
||||
sessions: parseInt(row.metricValues?.[1]?.value ?? '0'),
|
||||
pageviews: parseInt(row.metricValues?.[2]?.value ?? '0'),
|
||||
}));
|
||||
|
||||
const topPages = (pagesRes[0].rows ?? []).map((row) => ({
|
||||
page: row.dimensionValues?.[0]?.value ?? '',
|
||||
views: parseInt(row.metricValues?.[0]?.value ?? '0'),
|
||||
users: parseInt(row.metricValues?.[1]?.value ?? '0'),
|
||||
}));
|
||||
|
||||
// 채널별 집계
|
||||
const channelMap: Record<string, number> = {};
|
||||
const deviceMap: Record<string, number> = {};
|
||||
(sourcesRes[0].rows ?? []).forEach((row) => {
|
||||
const channel = row.dimensionValues?.[0]?.value ?? 'Unknown';
|
||||
const device = row.dimensionValues?.[1]?.value ?? 'Unknown';
|
||||
const sessions = parseInt(row.metricValues?.[0]?.value ?? '0');
|
||||
channelMap[channel] = (channelMap[channel] ?? 0) + sessions;
|
||||
deviceMap[device] = (deviceMap[device] ?? 0) + sessions;
|
||||
});
|
||||
const sources = Object.entries(channelMap)
|
||||
.map(([channel, sessions]) => ({ channel, sessions }))
|
||||
.sort((a, b) => b.sessions - a.sessions);
|
||||
const devices = Object.entries(deviceMap)
|
||||
.map(([device, sessions]) => ({ device, sessions }))
|
||||
.sort((a, b) => b.sessions - a.sessions);
|
||||
|
||||
return NextResponse.json({ summary, daily, topPages, sources, devices });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '알 수 없는 오류';
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
1152
package-lock.json
generated
1152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.79.0",
|
||||
"@google-analytics/data": "^5.2.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.99.0",
|
||||
|
||||
139
public/marketing/quote-cafe24.svg
Normal file
139
public/marketing/quote-cafe24.svg
Normal file
@@ -0,0 +1,139 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="700" viewBox="0 0 1200 700">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#1e1b4b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cardA" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e293b"/>
|
||||
<stop offset="100%" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cardB" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1d3461"/>
|
||||
<stop offset="100%" stop-color="#1e3a8a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cardC" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e293b"/>
|
||||
<stop offset="100%" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="badgeGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#ef4444"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 배경 -->
|
||||
<rect width="1200" height="700" fill="url(#bg)"/>
|
||||
<circle cx="200" cy="150" r="300" fill="#3b82f6" opacity="0.04"/>
|
||||
<circle cx="1000" cy="500" r="250" fill="#7c3aed" opacity="0.05"/>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<text x="600" y="58" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="13" fill="#94a3b8" text-anchor="middle" letter-spacing="3">카페24 자사몰 리뉴얼 · 3브랜드몰 구조 구현</text>
|
||||
<text x="600" y="98" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="32" fill="white" font-weight="800" text-anchor="middle" letter-spacing="-0.5">서비스 옵션 견적</text>
|
||||
<line x1="480" y1="116" x2="720" y2="116" stroke="#334155" stroke-width="1"/>
|
||||
|
||||
<!-- ── OPTION A 카드 ── -->
|
||||
<rect x="60" y="140" width="330" height="480" rx="16" fill="url(#cardA)" stroke="#334155" stroke-width="1.5"/>
|
||||
<rect x="60" y="140" width="330" height="5" rx="3" fill="#475569"/>
|
||||
|
||||
<text x="225" y="188" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="11" fill="#64748b" text-anchor="middle" letter-spacing="2">OPTION</text>
|
||||
<text x="225" y="216" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="26" fill="white" font-weight="800" text-anchor="middle">A</text>
|
||||
<text x="225" y="244" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">디자인 적용 기본형</text>
|
||||
|
||||
<line x1="90" y1="258" x2="360" y2="258" stroke="#1e293b" stroke-width="1.5"/>
|
||||
|
||||
<text x="225" y="294" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="34" fill="white" font-weight="800" text-anchor="middle">150<tspan font-size="16" fill="#94a3b8">만원</tspan></text>
|
||||
<text x="225" y="320" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="12" fill="#64748b" text-anchor="middle">작업기간 10일</text>
|
||||
|
||||
<line x1="90" y1="336" x2="360" y2="336" stroke="#1e293b" stroke-width="1.5"/>
|
||||
|
||||
<!-- A 포함 내용 -->
|
||||
<g font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="13" fill="#94a3b8">
|
||||
<text x="100" y="364">✓ 완성 디자인 카페24 스킨 적용</text>
|
||||
<text x="100" y="392">✓ PC + 모바일 반응형</text>
|
||||
<text x="100" y="420">✓ 단일 브랜드 기준</text>
|
||||
<text x="100" y="448">✓ 납품 후 14일 무상 AS</text>
|
||||
<text x="100" y="476" fill="#475569">✗ 브랜드몰 분기 구조</text>
|
||||
<text x="100" y="504" fill="#475569">✗ 기능 커스터마이징</text>
|
||||
<text x="100" y="532" fill="#475569">✗ 속도 최적화</text>
|
||||
</g>
|
||||
|
||||
<rect x="90" y="574" width="270" height="38" rx="8" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
|
||||
<text x="225" y="598" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="13" fill="#64748b" text-anchor="middle" font-weight="600">기본 적용만 필요할 때</text>
|
||||
|
||||
|
||||
<!-- ── OPTION B 카드 (추천) ── -->
|
||||
<rect x="435" y="120" width="330" height="520" rx="16" fill="url(#cardB)" stroke="#3b82f6" stroke-width="2"/>
|
||||
<rect x="435" y="120" width="330" height="5" rx="3" fill="url(#badgeGrad)"/>
|
||||
|
||||
<!-- 추천 배지 -->
|
||||
<rect x="530" y="104" width="110" height="26" rx="13" fill="url(#badgeGrad)"/>
|
||||
<text x="585" y="121" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="800">✦ 추천 옵션</text>
|
||||
|
||||
<text x="600" y="172" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="11" fill="#93c5fd" text-anchor="middle" letter-spacing="2">OPTION</text>
|
||||
<text x="600" y="200" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="26" fill="white" font-weight="800" text-anchor="middle">B</text>
|
||||
<text x="600" y="228" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="14" fill="#93c5fd" text-anchor="middle">디자인 + 3브랜드몰 구조</text>
|
||||
|
||||
<line x1="465" y1="242" x2="735" y2="242" stroke="#1e3a8a" stroke-width="1.5"/>
|
||||
|
||||
<text x="600" y="280" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="34" fill="white" font-weight="800" text-anchor="middle">280<tspan font-size="16" fill="#93c5fd">만원</tspan></text>
|
||||
<text x="600" y="306" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="12" fill="#60a5fa" text-anchor="middle">작업기간 20일</text>
|
||||
|
||||
<line x1="465" y1="322" x2="735" y2="322" stroke="#1e3a8a" stroke-width="1.5"/>
|
||||
|
||||
<!-- B 포함 내용 -->
|
||||
<g font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="13" fill="#bfdbfe">
|
||||
<text x="475" y="350">✓ 완성 디자인 카페24 스킨 적용</text>
|
||||
<text x="475" y="378">✓ PC + 모바일 반응형</text>
|
||||
<text x="475" y="406">✓ 1사이트 → 3브랜드몰 분기 구조</text>
|
||||
<text x="475" y="434">✓ 브랜드별 독립 운영 환경</text>
|
||||
<text x="475" y="462">✓ 납품 후 14일 무상 AS</text>
|
||||
<text x="475" y="490" fill="#64748b">✗ 고급 기능 커스터마이징</text>
|
||||
<text x="475" y="518" fill="#64748b">✗ 속도 최적화 포함</text>
|
||||
</g>
|
||||
|
||||
<rect x="465" y="574" width="270" height="46" rx="10" fill="#2563eb"/>
|
||||
<text x="600" y="598" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="14" fill="white" text-anchor="middle" font-weight="800">요청사항에 가장 적합</text>
|
||||
<text x="600" y="614" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="11" fill="#bfdbfe" text-anchor="middle">빠른 진행 + 3브랜드 완성</text>
|
||||
|
||||
|
||||
<!-- ── OPTION C 카드 ── -->
|
||||
<rect x="810" y="140" width="330" height="480" rx="16" fill="url(#cardC)" stroke="#334155" stroke-width="1.5"/>
|
||||
<rect x="810" y="140" width="330" height="5" rx="3" fill="#7c3aed"/>
|
||||
|
||||
<text x="975" y="188" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="11" fill="#64748b" text-anchor="middle" letter-spacing="2">OPTION</text>
|
||||
<text x="975" y="216" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="26" fill="white" font-weight="800" text-anchor="middle">C</text>
|
||||
<text x="975" y="244" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="14" fill="#a78bfa" text-anchor="middle">풀 리뉴얼 패키지</text>
|
||||
|
||||
<line x1="840" y1="258" x2="1110" y2="258" stroke="#1e293b" stroke-width="1.5"/>
|
||||
|
||||
<text x="975" y="294" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="34" fill="white" font-weight="800" text-anchor="middle">450<tspan font-size="16" fill="#a78bfa">만원</tspan></text>
|
||||
<text x="975" y="320" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="12" fill="#64748b" text-anchor="middle">작업기간 30일</text>
|
||||
|
||||
<line x1="840" y1="336" x2="1110" y2="336" stroke="#1e293b" stroke-width="1.5"/>
|
||||
|
||||
<!-- C 포함 내용 -->
|
||||
<g font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="13" fill="#c4b5fd">
|
||||
<text x="850" y="364">✓ Option B 전체 포함</text>
|
||||
<text x="850" y="392">✓ 상품 필터·검색 커스터마이징</text>
|
||||
<text x="850" y="420">✓ 장바구니·결제 흐름 최적화</text>
|
||||
<text x="850" y="448">✓ 관리자 편의 기능 추가</text>
|
||||
<text x="850" y="476">✓ 속도 최적화</text>
|
||||
<text x="850" y="504">✓ 배포 후 안정화 포함</text>
|
||||
<text x="850" y="532">✓ 납품 후 30일 무상 AS</text>
|
||||
</g>
|
||||
|
||||
<rect x="840" y="574" width="270" height="38" rx="8" fill="#4c1d95" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<text x="975" y="598" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="13" fill="#c4b5fd" text-anchor="middle" font-weight="600">완성도·확장성 최우선일 때</text>
|
||||
|
||||
|
||||
<!-- 하단 공통 안내 -->
|
||||
<rect x="60" y="648" width="1080" height="38" rx="8" fill="#0f172a" stroke="#1e293b" stroke-width="1"/>
|
||||
<text x="600" y="672" font-family="'Malgun Gothic','Apple SD Gothic Neo',sans-serif" font-size="12" fill="#475569" text-anchor="middle">
|
||||
계약서 필수 · 선금 50% / 잔금 50% · 납기 지연 시 1일당 1% 차감 · 결제 모듈·API 연동 등 추가 기능은 파일 검토 후 협의
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
Reference in New Issue
Block a user