Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
31 changes: 31 additions & 0 deletions src/assets/logo/social-logo/circle/meta-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion src/components/common/dropdownmenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export function DropdownMenu({
trigger,
items,
className,
menuClassName,
"aria-label": ariaLabel,
}: {
trigger: React.ReactNode | ((open: boolean) => React.ReactNode);
items: TMenuItem[];
className?: string;
menuClassName?: string;
"aria-label"?: string;
}) {
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -57,7 +59,10 @@ export function DropdownMenu({
<div
id={menuId}
role="menu"
className="absolute right-0 top-full mt-2 w-56 max-w-[calc(100vw-40px)] rounded-component-md bg-brand-200 border border-gray-100 py-3 px-1 shadow-Medium z-50 animate-modal-content origin-top-right"
className={twMerge(
"absolute right-0 top-full mt-2 w-56 max-w-[calc(100vw-40px)] rounded-component-md bg-brand-200 border border-gray-100 py-3 px-1 shadow-Medium z-50 animate-modal-content origin-top-right",
menuClassName,
)}
>
<div className="space-y-1">
{items.map((it, idx) => (
Expand Down
50 changes: 50 additions & 0 deletions src/components/dashboard/charts/AdStatusChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { memo, useMemo } from "react";
import { twMerge } from "tailwind-merge";

import type { IAdCount } from "@/types/dashboard/platform";

const PLATFORM_COLORS: Record<string, string> = {
GOOGLE: "bg-status-blue",
NAVER: "bg-status-green",
META: "bg-text-auth-sub",
};

export const AdStatusChart = memo(({ data }: { data: IAdCount[] }) => {
const total = useMemo(
() => data.reduce((acc, curr) => acc + curr.count, 0),
[data],
);
Comment thread
YermIm marked this conversation as resolved.

return (
<div className="flex flex-1 flex-col gap-1 justify-center px-3">
{/* 개수 라벨 */}
<div className="flex w-full h-6 relative">
{data.map((item) => (
<div
key={item.provider}
style={{ width: `${(item.count / total) * 100}%` }}
className="flex justify-center text-caption text-text-sub tabular-nums"
>
{item.count}개
</div>
))}
</div>

{/* 세그먼트 바 */}
<div className="flex w-full h-10 rounded-component-sm gap-1.5">
{data.map((item) => (
<div
key={item.provider}
style={{ width: `${(item.count / total) * 100}%` }}
className={twMerge(
PLATFORM_COLORS[item.provider],
"h-full rounded-sm",
)}
/>
))}
</div>
</div>
);
});

export default AdStatusChart;
47 changes: 47 additions & 0 deletions src/components/dashboard/charts/PerformanceEfficiencyChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { lazy, memo, Suspense, useMemo } from "react";

import type { IPlatformPerformance } from "@/types/dashboard/platform";
import { PLATFORM_MAP } from "@/types/dashboard/platform";

import { getMixedChartOptions } from "./performanceEfficiencyChart.config";

const Chart = lazy(() => import("react-apexcharts"));

export const PerformanceEfficiencyChart = memo(
({ data }: { data: IPlatformPerformance[] }) => {
const categories = useMemo(
() => data.map((d) => PLATFORM_MAP[d.provider] || d.provider),
[data],
);
const options = useMemo(
() => getMixedChartOptions(categories),
[categories],
);

const series = [
{
name: "클릭률(CTR)",
type: "column", // 세로 막대
data: data.map((d) => (d.clicks / d.impressions) * 100),
},
Comment thread
YermIm marked this conversation as resolved.
{
name: "전환율(CVR)",
type: "column", // 세로 막대
data: data.map((d) => d.conversion),
},
{
name: "노출수",
type: "line", // 점
data: data.map((d) => d.impressions),
},
];

return (
<Suspense fallback={<div className="h-40" />}>
<Chart options={options} series={series} height={150} />
</Suspense>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
);

export default PerformanceEfficiencyChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { ApexOptions } from "apexcharts";

export const getMixedChartOptions = (categories: string[]): ApexOptions => ({
chart: {
toolbar: { show: false },
fontFamily: "Pretendard",
zoom: { enabled: false },
selection: { enabled: false },
},
stroke: {
show: false,
width: 0,
},
markers: {
size: [0, 0, 6], // 막대 = 0, 점 = 6
strokeWidth: 0,
hover: { sizeOffset: 2 },
},
plotOptions: {
bar: {
columnWidth: "60%",
borderRadius: 4,
borderRadiusApplication: "end",
},
},
colors: ["#0084fe", "#546171", "#22c55e"], // 블루, 레드, 그린
xaxis: {
categories: categories,
labels: {
style: {
fontSize: "14px",
fontWeight: 500,
colors: "#212121",
},
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: [
{
seriesName: "클릭률",
labels: {
formatter: (val) => `${val.toFixed(1)}%`,
Comment thread
YermIm marked this conversation as resolved.
style: {
colors: "#8B8B8F",
fontSize: "12px",
},
},
},
{ show: false, seriesName: "전환율" },
{
opposite: true,
seriesName: "노출수",
labels: {
offsetX: -10,
formatter: (val) => val.toLocaleString(),
style: {
colors: "#8B8B8F",
fontSize: "12px",
},
},
},
],
tooltip: {
shared: true,
intersect: false,
y: {
formatter: (val, { seriesIndex }) => {
if (seriesIndex === 0 || seriesIndex === 1) {
return `${val.toFixed(2)}%`;
}
return val.toLocaleString();
},
},
},
grid: {
borderColor: "#f1f1f1",
yaxis: { lines: { show: true } },
padding: {
bottom: -15,
top: -15,
},
},
legend: { show: false },
});
90 changes: 90 additions & 0 deletions src/components/dashboard/platform/PlatformDetailCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { memo } from "react";

import type { IPlatformPerformance } from "@/types/dashboard/platform";
import { PLATFORM_MAP } from "@/types/dashboard/platform";

import Card from "@/components/common/card/Card";
import StatCard from "@/components/common/card/StatCard";

import GoogleLogo from "@/assets/logo/social-logo/circle/google-circle.svg?react";
import MetaLogo from "@/assets/logo/social-logo/circle/meta-circle.svg?react";
import NaverLogo from "@/assets/logo/social-logo/circle/naver-circle.svg?react";

const PLATFORM_LOGOS: Record<string, React.ReactNode> = {
GOOGLE: <GoogleLogo className="w-10 h-8" />,
NAVER: <NaverLogo className="w-10 h-8" />,
META: <MetaLogo className="w-10 h-8" />,
};

export const PlatformDetailCard = memo(
({ data }: { data: IPlatformPerformance }) => {
const {
provider,
impressions,
clicks,
conversion,
ROAS,
impressionChangeRate,
clickChangeRate,
cvrChangeRate,
ROASChangeRate,
} = data;

const innerCardClass =
"shadow-none! hover:shadow-none! rounded-component-md! p-2! gap-2!";

Comment thread
YermIm marked this conversation as resolved.
return (
<Card className="flex-1 p-7 bg-white/80 backdrop-blur-sm">
{/* 로고 + 이름 */}
<div className="flex items-center gap-2 mb-8">
<div className="shrink-0">{PLATFORM_LOGOS[provider]}</div>
<h3 className="font-heading4 font-semibold! text-text-main truncate">
{PLATFORM_MAP[provider]}
</h3>
</div>

{/* 지표 */}
<div className="grid grid-cols-2 gap-2">
<StatCard
title="노출수"
value={impressions.toLocaleString()}
trend={{
direction: impressionChangeRate > 0 ? "up" : "down",
value: `${Math.abs(impressionChangeRate * 100).toFixed(1)}%`,
Comment thread
YermIm marked this conversation as resolved.
Outdated
}}
className={innerCardClass}
/>
<StatCard
title="클릭수"
value={clicks.toLocaleString()}
trend={{
direction: clickChangeRate > 0 ? "up" : "down",
value: `${Math.abs(clickChangeRate * 100).toFixed(1)}%`,
}}
className={innerCardClass}
/>
<StatCard
title="전환율"
value={`${conversion}%`}
trend={{
direction: cvrChangeRate > 0 ? "up" : "down",
value: `${Math.abs(cvrChangeRate * 100).toFixed(1)}%`,
}}
className={innerCardClass}
/>
<StatCard
title="ROAS"
value={`${ROAS.toLocaleString()}%`}
trend={{
direction: ROASChangeRate > 0 ? "up" : "down",
value: `${Math.abs(ROASChangeRate * 100).toFixed(1)}%`,
}}
className={innerCardClass}
/>
</div>
</Card>
);
},
);

export default PlatformDetailCard;
64 changes: 64 additions & 0 deletions src/components/dashboard/platform/TopPerformanceList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { memo } from "react";

import type { IRoasRanking } from "@/types/dashboard/platform";
import { PLATFORM_MAP } from "@/types/dashboard/platform";

import { TrendBadge } from "@/components/common/card/StatCard";

import GoogleLogo from "@/assets/logo/social-logo/circle/google-circle.svg?react";
import MetaLogo from "@/assets/logo/social-logo/circle/meta-circle.svg?react";
import NaverLogo from "@/assets/logo/social-logo/circle/naver-circle.svg?react";

interface ITopPerformanceListProps {
rankings: IRoasRanking[];
}

const PlatformInfo: Record<string, { name: string; logo: React.ReactNode }> = {
GOOGLE: {
name: PLATFORM_MAP.GOOGLE,
logo: <GoogleLogo className="w-8 h-8" />,
},
NAVER: { name: PLATFORM_MAP.NAVER, logo: <NaverLogo className="w-8 h-8" /> },
META: { name: PLATFORM_MAP.META, logo: <MetaLogo className="w-8 h-8" /> },
};

export const TopPerformanceList = memo(function TopPerformanceList({
rankings,
}: ITopPerformanceListProps) {
return (
<div className="flex-1 flex flex-col justify-center gap-6 w-full pt-3">
{rankings.map((item) => {
const info = PlatformInfo[item.provider] || {
name: item.provider,
logo: null,
};
const diffRate = item.diffRate ?? 0;
const isUp = diffRate >= 0;

return (
<div key={item.provider} className="flex items-center gap-4 w-full">
<div className="flex flex-1 items-center gap-4 min-w-0">
<span className="font-body1 text-text-sub w-4 shrink-0">
{item.rank}
</span>
<div className="shrink-0">{info.logo}</div>
<span className="font-body1 text-text-main truncate">
{info.name}
</span>
</div>
<div className="flex items-center gap-4 shrink-0">
<span className="font-heading4 text-text-main tabular-nums whitespace-nowrap">
{item.roas.toFixed(2)}%
</span>
<TrendBadge
direction={isUp ? "up" : "down"}
value={`${Math.abs(diffRate)}%`}
/>
Comment thread
YermIm marked this conversation as resolved.
Outdated
</div>
</div>
);
})}
</div>
);
});
export default TopPerformanceList;
Loading
Loading