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
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@
"@google-analytics/data": "^7.2.0",
"@tanstack/react-query": "^5.101.4",
"@uiw/react-codemirror": "^4.25.12",
"axios": "^1.19.0",
"axios": "^1.20.0",
"babel-plugin-react-compiler": "^1.0.0",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"framer-motion": "^12.43.0",
"jwt-decode": "^4.0.0",
"next": "^15.5.25",
"next": "^15.5.28",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
Expand Down Expand Up @@ -68,7 +68,9 @@
"pnpm": {
"overrides": {
"postcss": "$postcss",
"next>sharp": "^0.35.3"
"next>sharp": "^0.35.5",
"ts-jest>handlebars": "^4.7.10",
"source-map-js": "^1.2.2"
}
},
"lint-staged": {
Expand Down
354 changes: 178 additions & 176 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions src/api/fetch/admin/api/useGetPostStatistics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { keepPreviousData } from "@tanstack/react-query";
import useAppQuery from "@/api/_base/query/useAppQuery";
import { GetPostStatisticsResponse, StatisticsPeriod } from "../types/StatisticsType";

export const useGetPostStatistics = ({ startDate, endDate }: StatisticsPeriod) => {
const params = new URLSearchParams({ startDate, endDate });

return useAppQuery<GetPostStatisticsResponse>(
"auth",
["admin-post-statistics", startDate, endDate],
`/admin/posts/statistics?${params.toString()}`,
// 기간을 바꾸는 동안 이전 값을 유지해 카드가 비었다 채워지며 높이가 바뀌지 않게 한다.
{ placeholderData: keepPreviousData }
);
};
15 changes: 15 additions & 0 deletions src/api/fetch/admin/api/useGetUserStatistics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { keepPreviousData } from "@tanstack/react-query";
import useAppQuery from "@/api/_base/query/useAppQuery";
import { GetUserStatisticsResponse, StatisticsPeriod } from "../types/StatisticsType";

export const useGetUserStatistics = ({ startDate, endDate }: StatisticsPeriod) => {
const params = new URLSearchParams({ startDate, endDate });

return useAppQuery<GetUserStatisticsResponse>(
"auth",
["admin-user-statistics", startDate, endDate],
`/admin/users/statistics?${params.toString()}`,
// 기간을 바꾸는 동안 이전 값을 유지해 카드가 비었다 채워지며 높이가 바뀌지 않게 한다.
{ placeholderData: keepPreviousData }
);
};
4 changes: 4 additions & 0 deletions src/api/fetch/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ export { useGetNoticeDraft } from "./api/useGetNoticeDraft";
export { usePostNoticeDraft } from "./api/usePostNoticeDraft";
export { usePutNoticeDraft } from "./api/usePutNoticeDraft";
export { useGetUserDetail } from "./api/useGetUserDetail";
export * from "./types/StatisticsType";

export { useGetUserStatistics } from "./api/useGetUserStatistics";
export { useGetPostStatistics } from "./api/useGetPostStatistics";
29 changes: 29 additions & 0 deletions src/api/fetch/admin/types/StatisticsType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType";

/** 통계 조회 기간. 한국 시간 기준 `YYYY-MM-DD`이며 시작일과 종료일을 모두 포함한다. */
export interface StatisticsPeriod {
startDate: string;
endDate: string;
}

export interface UserStatistics {
/** 종료일 기준 전체 회원 수 */
totalUserCount: number;
/** 기간 중 가입 수 */
signupCount: number;
/** 기간 중 탈퇴 수 */
withdrawalCount: number;
}

export interface PostStatistics {
/** 종료일 기준 전체 게시글 수 */
totalPostCount: number;
/** 기간 중 분실 게시글 최초 공개 수 */
lostPostCount: number;
/** 기간 중 발견 게시글 최초 공개 수 */
foundPostCount: number;
}

export interface GetUserStatisticsResponse extends ApiBaseResponseType<UserStatistics> {}

export interface GetPostStatisticsResponse extends ApiBaseResponseType<PostStatistics> {}
10 changes: 8 additions & 2 deletions src/app/(admin)/admin/_components/AdminNotice/AdminNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { cn } from "@/utils";

interface AdminNoticeProps {
title: string;
description: string;
className?: string;
}

/** 외부 연동 설정이 없거나 조회에 실패했을 때 보여주는 안내입니다. 서비스 통계와 DB 조회 화면에서 씁니다. */
const AdminNotice = ({ title, description }: AdminNoticeProps) => {
const AdminNotice = ({ title, description, className }: AdminNoticeProps) => {
return (
<div role="status" className="rounded-[14px] px-5 py-6 bg-fill-neutral-subtle-default">
<div
role="status"
className={cn("rounded-[14px] px-5 py-6 bg-fill-neutral-subtle-default", className)}
>
<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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
fetchAnalyticsReport,
hasAnalyticsConfig,
} from "../../_utils/fetchAnalyticsReport/fetchAnalyticsReport";
import { getAnalyticsPeriods } from "../../_utils/getAnalyticsPeriods/getAnalyticsPeriods";
import { getServiceUrl } from "@/utils";
import {
AnalyticsRangeTabs,
AnalyticsRankList,
AnalyticsSummaryCards,
ServiceStatisticsCards,
VisitorsBarChart,
} from "./_internal";
import AdminNotice from "../AdminNotice/AdminNotice";
Expand Down Expand Up @@ -73,7 +75,40 @@ const AnalyticsCharts = ({

return (
<>
<AnalyticsSummaryCards summary={report.summary} previousLabel={previousLabel} />
<AnalyticsSummaryCards
sectionLabel="방문 요약"
previousLabel={previousLabel}
cards={[
{
key: "activeUsers",
label: "방문자",
help: "기간 중 한 번 이상 방문한 사용자",
current: report.summary.current.activeUsers,
previous: report.summary.previous.activeUsers,
},
{
key: "newUsers",
label: "신규 방문자",
help: "처음 방문한 사용자",
current: report.summary.current.newUsers,
previous: report.summary.previous.newUsers,
},
{
key: "sessions",
label: "방문 횟수",
help: "세션 수",
current: report.summary.current.sessions,
previous: report.summary.previous.sessions,
},
{
key: "pageViews",
label: "페이지뷰",
help: "조회한 화면 수",
current: report.summary.current.pageViews,
previous: report.summary.previous.pageViews,
},
]}
/>

<div className="grid grid-cols-1 gap-10 pc:grid-cols-2 pc:gap-8">
<VisitorsBarChart
Expand Down Expand Up @@ -130,8 +165,9 @@ const AnalyticsCharts = ({
/** 운영 앱 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}`;
const { previousLabel } =
ANALYTICS_RANGES.find((item) => item.value === range) ?? ANALYTICS_RANGES[0];
const periods = getAnalyticsPeriods(new Date(), range);

let content;
if (!hasAnalyticsConfig()) {
Expand Down Expand Up @@ -162,11 +198,35 @@ const AnalyticsDashboard = async ({ rangeParam, basePath }: AnalyticsDashboardPr
<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}과 비교한
{periods.current.startDate} ~ {periods.current.endDate} (한국 시간). 증감은{" "}
{previousLabel}({periods.previous.startDate} ~ {periods.previous.endDate})과 비교한
값이에요.
</p>
</div>
{content}

<section aria-labelledby="analytics-service-title" className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 id="analytics-service-title" className="text-h3-semibold text-layout-header-default">
회원과 게시글
</h2>
<p className="text-caption1-regular text-layout-body-default">
서비스 서버가 집계한 값이에요.
</p>
</div>
<ServiceStatisticsCards {...periods} previousLabel={previousLabel} />
</section>

<section aria-labelledby="analytics-visit-title" className="flex flex-col gap-8">
<div className="flex flex-col gap-1">
<h2 id="analytics-visit-title" className="text-h3-semibold text-layout-header-default">
방문
</h2>
<p className="text-caption1-regular text-layout-body-default">
운영 앱의 Google Analytics 기준이며 10분마다 갱신돼요.
</p>
</div>
{content}
</section>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const AnalyticsRangeTabs = ({ range, basePath }: AnalyticsRangeTabsProps) => {
: "text-neutralInversed-normal-default bg-fill-neutralInversed-normal-default hover:text-black hover:bg-fill-neutralInversed-normal-hover"
)}
>
최근 {item.label}
{item.label}
</Link>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import { cn } from "@/utils";
import { AnalyticsSummary, AnalyticsSummaryValues } from "../../../../_types/AnalyticsReport";
import { getChangeRate } from "../../../../_utils/getChangeRate/getChangeRate";

export interface SummaryCard {
key: string;
label: string;
help: string;
/** 아직 값을 받지 못했으면 `null`이고, 같은 높이의 스켈레톤으로 자리를 잡는다. */
current: number | null;
/** 비교할 값을 받지 못했으면 `null`이고, 증감 자리에 비교할 값이 없다고 표시한다. */
previous: number | null;
}

interface AnalyticsSummaryCardsProps {
summary: AnalyticsSummary;
/** 섹션의 접근 가능한 이름 */
sectionLabel: string;
cards: SummaryCard[];
/** 비교 기간 라벨. 예: "이전 7일" */
previousLabel: string;
/** 새 기간 값을 받는 동안 이전 값을 흐리게 보여준다. */
isStale?: boolean;
}

const CARDS: { key: keyof AnalyticsSummaryValues; label: string; help: string }[] = [
{ key: "activeUsers", label: "방문자", help: "기간 중 한 번 이상 방문한 사용자" },
{ key: "newUsers", label: "신규 방문자", help: "처음 방문한 사용자" },
{ key: "sessions", label: "방문 횟수", help: "세션 수" },
{ key: "pageViews", label: "페이지뷰", help: "조회한 화면 수" },
{ key: "signUps", label: "가입", help: "GA 가입 완료 이벤트 수" },
{ key: "postCompletes", label: "게시글 작성", help: "GA 게시글 작성 완료 이벤트 수" },
];

const formatCount = (value: number) => value.toLocaleString("ko-KR");

const ChangeBadge = ({ current, previous }: { current: number; previous: number }) => {
const rate = getChangeRate(current, previous);
const ChangeBadge = ({ current, previous }: { current: number; previous: number | null }) => {
const rate = previous === null ? null : getChangeRate(current, previous);

if (rate === null) {
return <span className="text-caption1-regular text-layout-body-default">비교할 값 없음</span>;
Expand All @@ -41,14 +45,23 @@ const ChangeBadge = ({ current, previous }: { current: number; previous: number
};

/** 기간 합계 지표를 카드로 보여주고, 바로 앞 같은 길이 기간과 비교한 증감률을 함께 표시합니다. */
const AnalyticsSummaryCards = ({ summary, previousLabel }: AnalyticsSummaryCardsProps) => {
return (
<section aria-label="기간 요약">
<dl className="grid grid-cols-2 gap-3 pc:grid-cols-3">
{CARDS.map(({ key, label, help }) => {
const current = summary.current[key];
const previous = summary.previous[key];
const AnalyticsSummaryCards = ({
sectionLabel,
cards,
previousLabel,
isStale = false,
}: AnalyticsSummaryCardsProps) => {
const isLoading = cards.some((card) => card.current === null);

return (
<section aria-label={sectionLabel} aria-busy={isLoading || isStale}>
<dl
className={cn(
"grid grid-cols-2 gap-3 transition-opacity duration-150 pc:grid-cols-3",
isStale && "opacity-50"
)}
>
{cards.map(({ key, label, help, current, previous }) => {
return (
<div
key={key}
Expand All @@ -58,15 +71,33 @@ const AnalyticsSummaryCards = ({ summary, previousLabel }: AnalyticsSummaryCards
<span className="text-body2-medium text-layout-header-default">{label}</span>
<span className="text-caption1-regular text-layout-body-default">{help}</span>
</dt>
<dd className="flex flex-col">
<span className="text-h2-bold text-layout-header-default">{formatCount(current)}</span>
<span className="flex items-center gap-1">
<ChangeBadge current={current} previous={previous} />
<span className="text-caption1-regular text-layout-body-default">
{previousLabel} {formatCount(previous)}
{current === null ? (
// 값이 들어왔을 때와 높이가 같도록 보이지 않는 글자로 줄 높이를 잡고 그 위에 막대를 그린다.
<dd className="flex flex-col" aria-hidden>
<span className="relative block">
<span className="invisible block text-h2-bold">0</span>
<span className="skeleton-animation absolute inset-y-1 left-0 w-16 rounded-md" />
</span>
<span className="relative block">
<span className="invisible block text-caption1-regular">0</span>
<span className="skeleton-animation absolute inset-y-0.5 left-0 w-28 rounded-md" />
</span>
</dd>
) : (
<dd className="flex flex-col">
<span className="text-h2-bold text-layout-header-default">
{formatCount(current)}
</span>
<span className="flex items-center gap-1">
<ChangeBadge current={current} previous={previous} />
{previous !== null && (
<span className="text-caption1-regular text-layout-body-default">
{previousLabel} {formatCount(previous)}
</span>
)}
</span>
</span>
</dd>
</dd>
)}
</div>
);
})}
Expand Down
Loading
Loading