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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@ NEXT_PUBLIC_API_URL=your_api_url

# 운영 앱(찾아줘!) 주소. 게시글 상세, 공지 상세, 비밀번호 변경 링크에 쓴다. 비우면 https://www.finditem.kr
NEXT_PUBLIC_SERVICE_URL=https://www.finditem.kr

# 서비스 통계(/admin/analytics)용 GA Data API 설정. 서버에서만 쓰므로 NEXT_PUBLIC_을 붙이지 않는다.
# GA 속성에 "뷰어" 권한을 준 Google Cloud 서비스 계정의 이메일과 비공개 키를 넣는다.
# 비공개 키는 줄바꿈을 \n으로 바꿔 한 줄로 넣는다.
GA_PROPERTY_ID=510805727
GA_CLIENT_EMAIL=your_service_account@your_project.iam.gserviceaccount.com
GA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const securityHeaders = [
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ?? "";

const nextConfig: NextConfig = {
// GA Data API 클라이언트는 gRPC(google-gax)를 써서 webpack으로 묶으면 내부 package.json을 찾지 못한다.
// 서버에서 node_modules 그대로 불러오도록 번들에서 뺀다.
serverExternalPackages: ["@google-analytics/data"],
experimental: {
reactCompiler: true,
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@google-analytics/data": "^7.2.0",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.19.0",
"babel-plugin-react-compiler": "^1.0.0",
Expand Down
375 changes: 373 additions & 2 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const AdminSectionNavItem = ({ label, items, footer }: AdminSectionNavItemProps)
pathname !== null && (pathname === href || pathname.startsWith(`${href}/`));

return (
<section aria-label={label} className="flex flex-col gap-[2px] px-5 py-6 pc:py-4">
<section aria-label={label} className="flex flex-col gap-[2px] px-5 py-6 pc:py-3">
<h2 className="text-body2-regular text-layout-body-default">{label}</h2>

<ul>
Expand All @@ -54,7 +54,7 @@ const AdminSectionNavItem = ({ label, items, footer }: AdminSectionNavItemProps)
<Link
href={href}
aria-current={isCurrent(href) ? "page" : undefined}
className="group flex items-center justify-between py-[10px] pc:-mx-3 pc:rounded-[10px] pc:px-3 pc:transition-colors pc:hover:bg-flatGray-25 pc:aria-[current=page]:bg-fill-brand-subtle-default"
className="group flex items-center justify-between py-[10px] pc:-mx-3 pc:py-2 pc:rounded-[10px] pc:px-3 pc:transition-colors pc:hover:bg-flatGray-25 pc:aria-[current=page]:bg-fill-brand-subtle-default"
>
<span className="text-body1-semibold text-neutral-strong-default pc:group-aria-[current=page]:text-brand-normal-default">
{title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const AdminProfile = () => {
const { email, nickname, profileImg } = data?.result || {};

return (
<header className="flex items-center justify-between px-5 py-[30px] pc:flex-col pc:items-stretch pc:gap-5">
<header className="flex items-center justify-between px-5 py-[30px] pc:flex-col pc:items-stretch pc:gap-4 pc:py-6">
{isLoading ? (
<SkeletonAdminProfile />
) : (
Expand Down
12 changes: 9 additions & 3 deletions src/app/(admin)/admin/_components/AdminSidebar/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import AdminProfile from "../AdminProfile/AdminProfile";
*
* @remarks
* - PC 미만에서는 렌더만 하고 숨기며, 그 크기에서는 `/admin` 메인이 같은 프로필과 메뉴를 보여줍니다.
* - 본문을 스크롤해도 화면에 고정됩니다. 메뉴가 화면 높이보다 길면 메뉴 영역만 안에서 스크롤됩니다.
* - macOS의 오버레이 스크롤바는 스크롤하기 전까지 보이지 않아 메뉴가 잘린 것처럼 보이므로, 웹킷 스크롤바를 직접 그려 항상 보이게 합니다.
*/

const AdminSidebar = () => {
return (
<aside
aria-label="관리자 사이드바"
className="hidden pc:sticky pc:top-0 pc:flex pc:h-dvh pc:w-[320px] pc:shrink-0 pc:flex-col pc:overflow-y-auto pc:border-r pc:border-divider-default"
className="hidden pc:sticky pc:top-0 pc:flex pc:h-dvh pc:w-[320px] pc:shrink-0 pc:flex-col pc:border-r pc:border-divider-default"
>
<AdminProfile />
<AdminMenuSection />
<div className="pc:shrink-0 pc:border-b pc:border-divider-default">
<AdminProfile />
</div>
<div className="pc:min-h-0 pc:flex-1 pc:overflow-y-auto [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#d4d4d4] [&::-webkit-scrollbar]:w-[6px]">
<AdminMenuSection />
</div>
</aside>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { ANALYTICS_RANGES, DEFAULT_ANALYTICS_RANGE } from "../../_constants/ANALYTICS_RANGES";
import { AnalyticsRange, AnalyticsReport } from "../../_types/AnalyticsReport";
import {
fetchAnalyticsReport,
hasAnalyticsConfig,
} from "../../_utils/fetchAnalyticsReport/fetchAnalyticsReport";
import { getServiceUrl } from "@/utils";
import {
AnalyticsNotice,
AnalyticsRangeTabs,
AnalyticsRankList,
AnalyticsSummaryCards,
VisitorsBarChart,
} from "./_internal";

interface AnalyticsDashboardProps {
/** `?range=` 쿼리 값. 목록에 없는 값이면 기본 기간을 쓴다. */
rangeParam?: string;
/** 기간 링크가 가리킬 경로 */
basePath: string;
}

const toRange = (value?: string): AnalyticsRange =>
ANALYTICS_RANGES.find((item) => item.value === value)?.value ?? DEFAULT_ANALYTICS_RANGE;

const toShortDate = (isoDate: string) => isoDate.slice(5).replace("-", ".");

const addDays = (isoDate: string, days: number) => {
const date = new Date(`${isoDate}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
};

/** GA 기본 채널 그룹 이름을 한국어로 바꿉니다. 목록에 없는 값은 그대로 보여줍니다. */
const CHANNEL_LABELS: Record<string, string> = {
Direct: "직접 방문",
"Organic Search": "검색",
"Organic Social": "소셜",
Referral: "외부 링크",
"Paid Search": "검색 광고",
Email: "이메일",
Unassigned: "미분류",
"Cross-network": "광고 네트워크",
"AI Assistant": "AI 서비스",
};

const SOURCE_LABELS: Record<string, string> = {
"(direct)": "직접 입력 또는 즐겨찾기",
"(not set)": "알 수 없음",
"(data not available)": "알 수 없음",
};

const AnalyticsCharts = ({
report,
previousLabel,
}: {
report: AnalyticsReport;
previousLabel: string;
}) => {
const dailyItems = report.daily.map(({ date, activeUsers }) => ({
key: date,
axisLabel: toShortDate(date),
tooltipLabel: toShortDate(date),
value: activeUsers,
}));

const weeklyItems = report.weekly.map(({ weekStart, activeUsers }) => ({
key: weekStart,
axisLabel: toShortDate(weekStart),
tooltipLabel: `${toShortDate(weekStart)} ~ ${toShortDate(addDays(weekStart, 6))}`,
value: activeUsers,
}));

return (
<>
<AnalyticsSummaryCards summary={report.summary} previousLabel={previousLabel} />

<div className="grid grid-cols-1 gap-10 pc:grid-cols-2 pc:gap-8">
<VisitorsBarChart
title="일별 방문자"
items={dailyItems}
emptyText="이 기간에 수집된 방문 기록이 없어요."
/>
<VisitorsBarChart
title="주간 방문자 (최근 12주)"
items={weeklyItems}
emptyText="최근 12주 동안 수집된 방문 기록이 없어요."
/>
</div>

<div className="grid grid-cols-1 gap-10 pc:grid-cols-3 pc:gap-8">
<AnalyticsRankList
title="인기 검색어"
unit="회"
emptyText="이 기간에 수집된 검색이 없어요."
items={report.searchTerms.map(({ term, count }) => ({
key: term,
label: term,
value: count,
}))}
/>
<AnalyticsRankList
title="유입 경로"
unit="명"
emptyText="이 기간에 수집된 방문이 없어요."
items={report.trafficSources.map(({ source, channel, users }) => ({
key: `${source}-${channel}`,
label: SOURCE_LABELS[source] ?? source,
sublabel: CHANNEL_LABELS[channel] ?? channel,
value: users,
}))}
/>
<AnalyticsRankList
title="많이 본 페이지"
unit="회"
emptyText="이 기간에 수집된 페이지 조회가 없어요."
items={report.topPages.map(({ path, title, views }) => ({
key: path,
label: title || path,
sublabel: path,
value: views,
href: getServiceUrl(path),
}))}
/>
</div>
</>
);
};

/** 운영 앱 GA 데이터를 보여주는 서비스 통계 대시보드입니다. 관리자 메인(PC)과 서비스 통계 화면에서 함께 씁니다. */
const AnalyticsDashboard = async ({ rangeParam, basePath }: AnalyticsDashboardProps) => {
const range = toRange(rangeParam);
const rangeLabel = ANALYTICS_RANGES.find((item) => item.value === range)?.label ?? "";
const previousLabel = `이전 ${rangeLabel}`;

let content;
if (!hasAnalyticsConfig()) {
content = (
<AnalyticsNotice
title="GA 연결 설정이 필요해요"
description="서버 환경 변수 GA_PROPERTY_ID, GA_CLIENT_EMAIL, GA_PRIVATE_KEY를 등록하면 통계가 표시돼요."
/>
);
} else {
try {
content = (
<AnalyticsCharts report={await fetchAnalyticsReport(range)} previousLabel={previousLabel} />
);
} catch (error) {
console.error("[analytics] GA 조회 실패", error);
content = (
<AnalyticsNotice
title="통계를 불러오지 못했어요"
description="GA 서비스 계정 권한과 환경 변수를 확인해 주세요."
/>
);
}
}

return (
<div className="flex flex-col gap-8 px-5 pb-12 pt-6">
<div className="flex flex-col gap-3">
<AnalyticsRangeTabs range={range} basePath={basePath} />
<p className="text-caption1-regular text-layout-body-default">
운영 앱의 Google Analytics 기준이며 10분마다 갱신돼요. 증감은 {previousLabel}과 비교한 값이에요.
</p>
</div>
{content}
</div>
);
};

export default AnalyticsDashboard;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface AnalyticsNoticeProps {
title: string;
description: string;
}

/** GA 설정이 없거나 조회에 실패했을 때 보여주는 안내입니다. */
const AnalyticsNotice = ({ title, description }: AnalyticsNoticeProps) => {
return (
<div role="status" className="rounded-[14px] bg-fill-neutral-subtle-default px-5 py-6">
<p className="text-body1-semibold text-layout-header-default">{title}</p>
<p className="mt-1 text-body2-regular text-layout-body-default">{description}</p>
</div>
);
};

export default AnalyticsNotice;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Link from "next/link";
import { cn } from "@/utils";
import { ANALYTICS_RANGES } from "../../../../_constants/ANALYTICS_RANGES";
import { AnalyticsRange } from "../../../../_types/AnalyticsReport";

interface AnalyticsRangeTabsProps {
range: AnalyticsRange;
/** 기간 링크가 가리킬 경로. 관리자 메인과 서비스 통계 화면에서 각자 자기 경로를 넘긴다. */
basePath: string;
}

/** 조회 기간을 고르는 링크 묶음입니다. 기간은 `?range=` 쿼리로 서버 컴포넌트에 전달됩니다. */
const AnalyticsRangeTabs = ({ range, basePath }: AnalyticsRangeTabsProps) => {
return (
<nav aria-label="조회 기간" className="flex flex-wrap gap-2">
{ANALYTICS_RANGES.map((item) => {
const isActive = item.value === range;

return (
<Link
key={item.value}
href={`${basePath}?range=${item.value}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"whitespace-nowrap rounded-full px-4 py-[6px] text-body2-medium transition-colors duration-150",
isActive
? "text-white bg-fill-neutralInversed-normal-enteredSelected"
: "text-neutralInversed-normal-default bg-fill-neutralInversed-normal-default hover:text-black hover:bg-fill-neutralInversed-normal-hover"
)}
>
최근 {item.label}
</Link>
);
})}
</nav>
);
};

export default AnalyticsRangeTabs;
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export interface AnalyticsRankItem {
key: string;
label: string;
sublabel?: string;
value: number;
/** 있으면 새 탭으로 여는 링크가 된다. */
href?: string;
}

interface AnalyticsRankListProps {
title: string;
items: AnalyticsRankItem[];
/** 값 뒤에 붙는 단위. 예: "회" */
unit: string;
emptyText: string;
}

/** 검색어, 유입 경로, 많이 본 페이지처럼 순위가 있는 값을 보여주는 목록입니다. */
const AnalyticsRankList = ({ title, items, unit, emptyText }: AnalyticsRankListProps) => {
const titleId = `analytics-rank-${title}`;
const max = Math.max(...items.map((item) => item.value), 1);

return (
<section aria-labelledby={titleId} className="min-w-0">
<h2 id={titleId} className="mb-3 text-h3-semibold text-layout-header-default">
{title}
</h2>

{items.length === 0 ? (
<p className="text-body2-regular text-layout-body-default">{emptyText}</p>
) : (
<ol className="flex flex-col gap-3">
{items.map((item, index) => {
const label = (
<span className="block truncate text-body2-medium text-layout-header-default">
{item.label}
</span>
);

return (
<li key={item.key} className="flex items-start gap-3">
<span className="w-5 shrink-0 pt-[1px] text-right text-body2-medium text-layout-body-default">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<div className="min-w-0 flex-1">
{item.href ? (
<a href={item.href} target="_blank" rel="noopener noreferrer" className="hover:underline">
{label}
</a>
) : (
label
)}
{item.sublabel && (
<span className="block truncate text-caption1-regular text-layout-body-default">
{item.sublabel}
</span>
)}
</div>
<span className="shrink-0 text-body2-medium text-layout-header-default">
{item.value.toLocaleString("ko-KR")}
{unit}
</span>
</div>
<div aria-hidden className="mt-1 h-1 rounded-full bg-fill-neutral-subtle-default">
<div
className="h-full rounded-full bg-fill-brand-normal-default"
style={{ width: `${(item.value / max) * 100}%` }}
/>
</div>
</div>
</li>
);
})}
</ol>
)}
</section>
);
};

export default AnalyticsRankList;
Loading
Loading