diff --git a/scripts/strip-cursor-attribution 2.mjs b/scripts/strip-cursor-attribution 2.mjs new file mode 100644 index 00000000..2ff8f730 --- /dev/null +++ b/scripts/strip-cursor-attribution 2.mjs @@ -0,0 +1,20 @@ +/** + * Cursor 에이전트가 붙이는 Git 메시지 trailer 제거. + * GitHub가 Co-authored-by를 파싱해 Cursor 아바타를 붙이는 것을 막기 위함. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +const path = process.argv[2]; +if (!path) process.exit(0); + +let text = readFileSync(path, "utf8"); +const next = text + .split("\n") + .filter( + (line) => + !/^Co-authored-by:\s*Cursor\b/i.test(line) && + !/^Made-with:\s*Cursor\b/i.test(line), + ) + .join("\n"); + +if (next !== text) writeFileSync(path, next); diff --git a/src/assets/icon/ai/warning 2.svg b/src/assets/icon/ai/warning 2.svg new file mode 100644 index 00000000..503175ff --- /dev/null +++ b/src/assets/icon/ai/warning 2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icon/chevron/chevron-down 2.svg b/src/assets/icon/chevron/chevron-down 2.svg new file mode 100644 index 00000000..a1200509 --- /dev/null +++ b/src/assets/icon/chevron/chevron-down 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/common/camera 2.svg b/src/assets/icon/common/camera 2.svg new file mode 100644 index 00000000..c86e1e5d --- /dev/null +++ b/src/assets/icon/common/camera 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/common/check-white 2.svg b/src/assets/icon/common/check-white 2.svg new file mode 100644 index 00000000..3b569791 --- /dev/null +++ b/src/assets/icon/common/check-white 2.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/icon/common/lock 2.svg b/src/assets/icon/common/lock 2.svg new file mode 100644 index 00000000..264b5367 --- /dev/null +++ b/src/assets/icon/common/lock 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/common/userProfileCircle 2.svg b/src/assets/icon/common/userProfileCircle 2.svg new file mode 100644 index 00000000..ad25b206 --- /dev/null +++ b/src/assets/icon/common/userProfileCircle 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/timeline/chevron-left 2.svg b/src/assets/icon/timeline/chevron-left 2.svg new file mode 100644 index 00000000..b180e6e9 --- /dev/null +++ b/src/assets/icon/timeline/chevron-left 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/timeline/chevron-right 2.svg b/src/assets/icon/timeline/chevron-right 2.svg new file mode 100644 index 00000000..4864a6f7 --- /dev/null +++ b/src/assets/icon/timeline/chevron-right 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/timeline/filter 2.svg b/src/assets/icon/timeline/filter 2.svg new file mode 100644 index 00000000..c5b95a30 --- /dev/null +++ b/src/assets/icon/timeline/filter 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/timeline/kebab 2.svg b/src/assets/icon/timeline/kebab 2.svg new file mode 100644 index 00000000..bb2ebad9 --- /dev/null +++ b/src/assets/icon/timeline/kebab 2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icon/timeline/sort 2.svg b/src/assets/icon/timeline/sort 2.svg new file mode 100644 index 00000000..4c287bc9 --- /dev/null +++ b/src/assets/icon/timeline/sort 2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/logo/service-logo/logo 2.svg b/src/assets/logo/service-logo/logo 2.svg new file mode 100644 index 00000000..f2b6747d --- /dev/null +++ b/src/assets/logo/service-logo/logo 2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/logo/social-logo/plain/google_ads 2.png b/src/assets/logo/social-logo/plain/google_ads 2.png new file mode 100644 index 00000000..587eaeef Binary files /dev/null and b/src/assets/logo/social-logo/plain/google_ads 2.png differ diff --git a/src/assets/logo/social-logo/plain/meta 2.svg b/src/assets/logo/social-logo/plain/meta 2.svg new file mode 100644 index 00000000..22806c9f --- /dev/null +++ b/src/assets/logo/social-logo/plain/meta 2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/logo/social-logo/wordmark/naver-wordmark 2.png b/src/assets/logo/social-logo/wordmark/naver-wordmark 2.png new file mode 100644 index 00000000..aa323f73 Binary files /dev/null and b/src/assets/logo/social-logo/wordmark/naver-wordmark 2.png differ diff --git a/src/assets/mockup/iOS app dock 2.png b/src/assets/mockup/iOS app dock 2.png new file mode 100644 index 00000000..139d5d90 Binary files /dev/null and b/src/assets/mockup/iOS app dock 2.png differ diff --git a/src/assets/mockup/iPad Air mockup 2.png b/src/assets/mockup/iPad Air mockup 2.png new file mode 100644 index 00000000..ef5f2af0 Binary files /dev/null and b/src/assets/mockup/iPad Air mockup 2.png differ diff --git a/src/assets/mockup/laptop_mockup 2.png b/src/assets/mockup/laptop_mockup 2.png new file mode 100644 index 00000000..68e41c58 Binary files /dev/null and b/src/assets/mockup/laptop_mockup 2.png differ diff --git a/src/components/auth/common/AuthFormShell 2.tsx b/src/components/auth/common/AuthFormShell 2.tsx new file mode 100644 index 00000000..5f4c084f --- /dev/null +++ b/src/components/auth/common/AuthFormShell 2.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +const shellBase = "mx-auto w-full max-w-130 px-6"; + +type TAuthFormShellVariant = "page" | "social" | "step"; + +const variantClasses: Record = { + page: "py-12", + social: "flex flex-col items-center", + step: "pb-12", +}; + +interface IAuthFormShellProps { + children: ReactNode; + variant?: TAuthFormShellVariant; + className?: string; +} + +export default function AuthFormShell({ + children, + variant = "step", + className, +}: IAuthFormShellProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/ComingSoonPlaceholder 2.tsx b/src/components/common/ComingSoonPlaceholder 2.tsx new file mode 100644 index 00000000..37d28778 --- /dev/null +++ b/src/components/common/ComingSoonPlaceholder 2.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; +import { memo } from "react"; +import { twMerge } from "tailwind-merge"; + +import WarningIcon from "@/assets/icon/ai/warning.svg?react"; + +export interface IComingSoonPlaceholderProps { + title: ReactNode; + description: ReactNode; + footer: ReactNode; + className?: string; +} + +const ComingSoonPlaceholder = memo(function ComingSoonPlaceholder({ + title, + description, + footer, + className, +}: IComingSoonPlaceholderProps) { + return ( +
+
+
+ +
+ +

+ {title} +

+ +

+ {description} +

+ +
+

{footer}

+
+
+
+ ); +}); + +export default ComingSoonPlaceholder; diff --git a/src/components/dashboard/charts/BudgetGaugeChart.tsx b/src/components/dashboard/charts/BudgetGaugeChart.tsx index ffbd6f45..49615fbf 100644 --- a/src/components/dashboard/charts/BudgetGaugeChart.tsx +++ b/src/components/dashboard/charts/BudgetGaugeChart.tsx @@ -142,7 +142,7 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({ -
+
- {/* 실시간 트래픽 변화 */} - - {isLoading ? ( - - ) : ( -
- Coming Soon -

- 실시간 클릭 트래픽 모니터링 기능을 준비하고 있습니다. -

-
- )} + {/* 플랫폼별 실시간 클릭수 비교 */} + + } + > +
+ +
{/* 개별 플랫폼 상세 */} diff --git a/src/components/dashboard/platform/PlatformDetailTable 2.tsx b/src/components/dashboard/platform/PlatformDetailTable 2.tsx new file mode 100644 index 00000000..7291f782 --- /dev/null +++ b/src/components/dashboard/platform/PlatformDetailTable 2.tsx @@ -0,0 +1,157 @@ +import { useMemo } from "react"; + +import type { IPlatformDailyPerformance } from "@/pages/dashboard/platform/platformDashboard.mock"; + +interface IPlatformDetailTableProps { + data: IPlatformDailyPerformance[]; +} + +function PlatformDetailTable({ data }: IPlatformDetailTableProps) { + // 합계 계산 + const total = useMemo(() => { + if (!data.length) return null; + const totalSpend = data.reduce((acc, curr) => acc + curr.spend, 0); + const totalImpressions = data.reduce( + (acc, curr) => acc + curr.impressions, + 0, + ); + const totalClicks = data.reduce((acc, curr) => acc + curr.clicks, 0); + const totalConversions = data.reduce( + (acc, curr) => acc + curr.conversions, + 0, + ); + return { + spend: totalSpend, + impressions: totalImpressions, + clicks: totalClicks, + ctr: totalImpressions > 0 ? (totalClicks / totalImpressions) * 100 : 0, + cpc: totalClicks > 0 ? totalSpend / totalClicks : 0, + conversions: totalConversions, + roas: + totalSpend > 0 + ? data.reduce((acc, curr) => acc + curr.roas * curr.spend, 0) / + totalSpend + : 0, + }; + }, [data]); + + return ( +
+ + +
+ + + + + + + + + + + + + + + {/* 합계 행 */} + {total && ( + + + + + + + + + + + )} + {/* 일별 데이터 */} + {data.map((row, idx) => ( + + + + + + + + + + + ))} + +
+ 날짜 + + 비용(지출) + + 노출 수 + + 클릭 수 + + CTR(클릭률) + + CPC + + 전환 수 + + ROAS +
합계 + ₩{total.spend.toLocaleString()} + + {total.impressions.toLocaleString()} + + {total.clicks.toLocaleString()} + + {total.ctr.toFixed(2)}% + + ₩{Math.round(total.cpc).toLocaleString()} + + {total.conversions.toLocaleString()} + + {Math.round(total.roas)}% +
+ {row.date} + + ₩{row.spend.toLocaleString()} + + {row.impressions.toLocaleString()} + + {row.clicks.toLocaleString()} + + {row.ctr.toFixed(2)}% + + ₩{row.cpc.toLocaleString()} + + {row.conversions.toLocaleString()} + + {row.roas}% +
+
+
+ ); +} + +export default PlatformDetailTable; diff --git a/src/components/dashboard/platform/PlatformTrafficChart.tsx b/src/components/dashboard/platform/PlatformTrafficChart.tsx new file mode 100644 index 00000000..73e80aa1 --- /dev/null +++ b/src/components/dashboard/platform/PlatformTrafficChart.tsx @@ -0,0 +1,162 @@ +import { memo, useMemo } from "react"; +import ReactApexChart from "react-apexcharts"; +import type { ApexOptions } from "apexcharts"; + +import { Skeleton } from "@/components/common/skeleton/Skeleton"; + +import type { IClickStreamResponse } from "@/pages/dashboard/platform/platformDashboard.mock"; + +interface IPlatformTrafficChartProps { + data: IClickStreamResponse | null; + platform: string; + isLoading?: boolean; +} + +const PLATFORM_COLORS: Record = { + GOOGLE: "#f9ab00", + NAVER: "#03c75a", + META: "#1877f2", +}; + +const PlatformTrafficChart = memo(function PlatformTrafficChart({ + data, + platform, + isLoading, +}: IPlatformTrafficChartProps) { + // 데이터 변환: minute 문자열 -> 타임스탬프 + const seriesData = useMemo(() => { + if (!data) return []; + return data.timeSeriesData.map((d) => { + const year = parseInt(d.minute.slice(0, 4), 10); + const month = parseInt(d.minute.slice(4, 6), 10) - 1; + const day = parseInt(d.minute.slice(6, 8), 10); + const hour = parseInt(d.minute.slice(8, 10), 10); + const min = parseInt(d.minute.slice(10, 12), 10); + return { + x: new Date(year, month, day, hour, min).getTime(), + y: d.count, + }; + }); + }, [data]); + + // X축 범위 계산 (최근 60분) + const { xMin, xMax } = useMemo(() => { + if (seriesData.length === 0) return { xMin: undefined, xMax: undefined }; + return { + xMin: seriesData[0].x, + xMax: seriesData[seriesData.length - 1].x, + }; + }, [seriesData]); + + const platformColor = PLATFORM_COLORS[platform] || "#0084fe"; + + // Y축 최대값 계산 + const yMax = useMemo(() => { + const counts = seriesData.map((d) => d.y); + const maxCount = counts.length > 0 ? Math.max(...counts) : 0; + if (maxCount <= 0) return 1000; + + const magnitude = Math.pow(10, Math.floor(Math.log10(maxCount))); + // 최소 단위 100 + const unit = magnitude >= 100 ? magnitude : 100; + return Math.ceil((maxCount * 1.2) / unit) * unit; + }, [seriesData]); + + const chartOptions: ApexOptions = { + chart: { + type: "area", + toolbar: { show: false }, + zoom: { enabled: false }, + fontFamily: "Pretendard", + animations: { enabled: true, speed: 800 }, + }, + dataLabels: { enabled: false }, + stroke: { + curve: "smooth", + width: 2, + colors: [platformColor], + }, + fill: { + type: "gradient", + gradient: { + shadeIntensity: 1, + opacityFrom: 0.45, + opacityTo: 0.05, + stops: [20, 100], + }, + }, + colors: [platformColor], + markers: { size: 0, hover: { size: 5 } }, + xaxis: { + type: "numeric", + min: xMin, + max: xMax, + tickAmount: 4, + labels: { + formatter: (val: string | number) => { + const ts = Number(val); + const d = new Date(ts); + const h = String(d.getHours()).padStart(2, "0"); + const m = String(d.getMinutes()).padStart(2, "0"); + return `${h}:${m}`; + }, + style: { colors: "#8b8b8f", fontSize: "12px" }, + }, + axisBorder: { show: false }, + axisTicks: { show: false }, + tooltip: { enabled: false }, + }, + yaxis: { + min: 0, + max: yMax, + tickAmount: 5, + labels: { + style: { colors: "#8b8b8f", fontSize: "12px" }, + formatter: (val) => { + const rounded = Math.round(val); + if (rounded <= 0) return ""; + if (rounded < 1000) return rounded.toLocaleString(); + return `${Math.round(rounded / 1000)}K`; + }, + }, + }, + grid: { + borderColor: "#f2f4f6", + xaxis: { lines: { show: false } }, + yaxis: { lines: { show: true } }, + padding: { + left: 16, + right: 24, + }, + }, + tooltip: { + x: { show: false }, + y: { formatter: (val) => `${val.toLocaleString()} 클릭` }, + theme: "light", + }, + }; + + const series = [ + { + name: "클릭수", + data: seriesData, + }, + ]; + + if (isLoading || !data) { + return ; + } + + return ( +
+ +
+ ); +}); + +export default PlatformTrafficChart; diff --git a/src/components/dashboard/platform/SinglePlatformView.tsx b/src/components/dashboard/platform/SinglePlatformView.tsx index fc498c16..7b4c9062 100644 --- a/src/components/dashboard/platform/SinglePlatformView.tsx +++ b/src/components/dashboard/platform/SinglePlatformView.tsx @@ -11,8 +11,8 @@ import BudgetGaugeChart, { statusBadgeVariant, } from "@/components/dashboard/charts/BudgetGaugeChart"; import PlatformDetailTable from "@/components/dashboard/platform/PlatformDetailTable"; +import PlatformTrafficChart from "@/components/dashboard/platform/PlatformTrafficChart"; -import AiButtonSvg from "@/assets/logo/service-logo/ai-요약버튼.svg?react"; import GoogleLogo from "@/assets/logo/social-logo/wordmark/google-wordmark.svg?react"; import MetaLogo from "@/assets/logo/social-logo/wordmark/meta-wordmark.svg?react"; import NaverLogo from "@/assets/logo/social-logo/wordmark/naver-wordmark.svg?react"; @@ -20,6 +20,7 @@ import { budgetStatusMock, performanceEfficiencyMock, platformDailyPerformanceMock, + platformTrafficMock, } from "@/pages/dashboard/platform/platformDashboard.mock"; const PLATFORM_LOGOS: Record< @@ -121,6 +122,15 @@ export default function SinglePlatformView({ return allData.slice(0, viewRange); }, [platform, viewRange]); + const PLATFORM_THEME_COLORS: Record = { + GOOGLE: "#f9ab00", + NAVER: "#03c75a", + META: "#1877f2", + }; + + const platformColor = + PLATFORM_THEME_COLORS[platform.toUpperCase()] || "#1877f2"; + return (
{/* platform header */} @@ -134,14 +144,6 @@ export default function SinglePlatformView({

{platform}

)}
- -
{/* top */} @@ -171,19 +173,26 @@ export default function SinglePlatformView({
+ } > -
- Coming Soon -

- 실시간 클릭 트래픽 모니터링 기능을 준비하고 있습니다. -

-
+
{budget ? ( -
+
) : ( diff --git a/src/components/landing/CTASection 2.tsx b/src/components/landing/CTASection 2.tsx new file mode 100644 index 00000000..079c5327 --- /dev/null +++ b/src/components/landing/CTASection 2.tsx @@ -0,0 +1,3 @@ +export default function CTASection() { + return <>; +} diff --git a/src/components/landing/FeatureSection 2.tsx b/src/components/landing/FeatureSection 2.tsx new file mode 100644 index 00000000..fb9ede34 --- /dev/null +++ b/src/components/landing/FeatureSection 2.tsx @@ -0,0 +1,3 @@ +export default function FeatureSection() { + return <>; +} diff --git a/src/components/landing/GuideOverviewChart 2.tsx b/src/components/landing/GuideOverviewChart 2.tsx new file mode 100644 index 00000000..be1feac3 --- /dev/null +++ b/src/components/landing/GuideOverviewChart 2.tsx @@ -0,0 +1,144 @@ +import { lazy, Suspense } from "react"; +import type { ApexOptions } from "apexcharts"; + +import Card from "@/components/common/card/Card"; +import ChartLegend from "@/components/common/chart/ChartLegend"; + +const ReactApexChart = lazy(() => import("react-apexcharts")); + +const splitIndex = 12; +const clicks = [ + 29600, 31100, 31600, 32100, 33600, 37100, 44600, 46100, 46600, 45600, 49600, + 52600, 53600, 51600, 47600, 45600, 43600, 42600, 45100, 40600, 37600, 35600, + 41600, 47600, 50600, +]; +const peak = Math.max(...clicks); +const targetPeak = 48500; +const normalizedClicks = clicks.map((value) => + Math.round((value / peak) * targetPeak), +); + +const actualSeries = normalizedClicks.map((y, i) => + i <= splitIndex ? { x: i, y } : { x: i, y: null }, +); +const projectedSeries = normalizedClicks.map((y, i) => + i >= splitIndex ? { x: i, y } : { x: i, y: null }, +); + +const options: ApexOptions = { + chart: { + type: "line", + toolbar: { show: false }, + zoom: { enabled: false }, + animations: { enabled: false }, + fontFamily: "Pretendard", + }, + dataLabels: { enabled: false }, + stroke: { + curve: "smooth", + width: [3.5, 3.2], + dashArray: [0, 6], + lineCap: "round", + }, + colors: ["var(--color-logo-2)", "var(--color-brand-500)"], + markers: { size: [0, 0] }, + tooltip: { enabled: false }, + xaxis: { + type: "numeric", + min: 0, + max: 24, + tickAmount: 24, + labels: { show: false }, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + yaxis: { + min: 0, + max: 50000, + tickAmount: 6, + labels: { + formatter: (val: number) => + val === 0 ? "" : `${(val / 1000).toFixed(0)}K`, + style: { colors: "var(--color-text-sub)", fontSize: "10px" }, + offsetX: -2, + }, + }, + grid: { + borderColor: "var(--color-bg-disabled)", + strokeDashArray: 5, + xaxis: { lines: { show: false } }, + yaxis: { lines: { show: true } }, + padding: { left: 12, right: 8 }, + }, + legend: { show: false }, + annotations: { + xaxis: [ + { + x: splitIndex, + borderColor: "var(--color-brand-500)", + strokeDashArray: 0, + }, + ], + points: [ + { + x: splitIndex, + y: normalizedClicks[splitIndex], + marker: { + size: 5, + fillColor: "var(--color-logo-2)", + strokeColor: "var(--color-logo-2)", + }, + }, + ], + }, +}; + +const series = [ + { name: "클릭수", data: actualSeries }, + { name: "예측 클릭수", data: projectedSeries }, +]; + +export default function GuideOverviewChart() { + return ( +
+ + } + > + + } + > +
+
+

+ 광고 클릭수 추이 +

+

+ 오후 12시 기준 클릭수 48,500 +

+

+ 전시간 대비 +1.9% +

+
+ +
+
+
+
+ ); +} diff --git a/src/components/landing/GuidePlatform 2.tsx b/src/components/landing/GuidePlatform 2.tsx new file mode 100644 index 00000000..ec056fd3 --- /dev/null +++ b/src/components/landing/GuidePlatform 2.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from "react"; +import { useState } from "react"; + +import ChevronDown from "@/assets/icon/chevron/chevron-down.svg?react"; +import GoogleAdsPlain from "@/assets/logo/social-logo/plain/google_ads.png"; +import MetaPlain from "@/assets/logo/social-logo/plain/meta.svg?react"; +import GoogleWordmark from "@/assets/logo/social-logo/wordmark/google-wordmark.svg?react"; +import KakaoWordmark from "@/assets/logo/social-logo/wordmark/kakao-wordmark.svg?react"; +import NaverWordmarkPng from "@/assets/logo/social-logo/wordmark/naver-wordmark.png"; + +type TPlatform = { + id: string; + label: string; + content: ReactNode; + offsetClass?: string; +}; + +const PLATFORMS: TPlatform[] = [ + { + id: "naver", + label: "Naver", + content: ( + Naver + ), + }, + { + id: "kakao", + label: "Kakao", + content: , + }, + { + id: "google", + label: "Google", + content: , + }, + { + id: "meta", + label: "Meta", + content: , + offsetClass: "-ml-2", + }, + { + id: "googleads", + label: "Google Ads", + content: ( + Google Ads + ), + offsetClass: "-ml-2", + }, +]; + +export default function GuidePlatform() { + const [isMenuOpen, setIsMenuOpen] = useState(true); + const [selectedIds, setSelectedIds] = useState([ + "googleads", + "meta", + "naver", + ]); + + function togglePlatform(id: string) { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id], + ); + } + + return ( +
+
+ + + {isMenuOpen && ( +
+
+ {PLATFORMS.map((platform) => { + const isSelected = selectedIds.includes(platform.id); + return ( + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/src/components/landing/GuideTimeline 2.tsx b/src/components/landing/GuideTimeline 2.tsx new file mode 100644 index 00000000..5ebf4d06 --- /dev/null +++ b/src/components/landing/GuideTimeline 2.tsx @@ -0,0 +1,175 @@ +import ChevronLeftIcon from "@/assets/icon/timeline/chevron-left.svg?react"; +import ChevronRightIcon from "@/assets/icon/timeline/chevron-right.svg?react"; +import FilterIcon from "@/assets/icon/timeline/filter.svg?react"; +import KebabIcon from "@/assets/icon/timeline/kebab.svg?react"; +import SortIcon from "@/assets/icon/timeline/sort.svg?react"; + +const columns = [ + { day: "M", date: 30, isWeekend: false }, + { day: "T", date: 31, isWeekend: false }, + { day: "W", date: 1, isWeekend: false }, + { day: "T", date: 2, isWeekend: false, isToday: true }, + { day: "F", date: 3, isWeekend: false }, + { day: "S", date: 4, isWeekend: true }, + { day: "S", date: 5, isWeekend: true }, + { day: "M", date: 6, isWeekend: false }, + { day: "T", date: 7, isWeekend: false }, + { day: "W", date: 8, isWeekend: false }, + { day: "T", date: 9, isWeekend: false }, + { day: "F", date: 10, isWeekend: false }, + { day: "S", date: 11, isWeekend: true }, + { day: "S", date: 12, isWeekend: true }, + { day: "M", date: 13, isWeekend: false }, + { day: "T", date: 14, isWeekend: false }, + { day: "W", date: 15, isWeekend: false }, +]; + +const cards = [ + { + id: 1, + title: "봄 프로모션 캠페인", + subtitle: "Google Ads · 전환", + colStart: 3.2, + colEnd: 9, + row: 1, + colorClass: "bg-status-blue", + }, + { + id: 2, + title: "리타겟팅 캠페인", + subtitle: "Meta · 트래픽", + colStart: 8.9, + colEnd: 15.3, + row: 2, + colorClass: "bg-logo-2", + }, + { + id: 3, + title: "브랜드 검색 캠페인", + subtitle: "Naver · 검색", + colStart: 2.4, + colEnd: 8, + row: 3, + colorClass: "bg-status-green", + }, +]; + +export default function GuideTimeline() { + const colWidth = 55; + const rowHeight = 92; + const rowOffset = 24; + const totalWidth = columns.length * colWidth; + + return ( +
+ {/* Top Navigation */} +
+
+ + + +
+ +
+ + 27 Dec - 4 Jan + +
+ +
+
+ + Sort +
+
+ + Filter +
+
+
+ + {/* Chart Area */} +
+
+ {/* Header (Dates) */} +
+ {columns.map((c, i) => ( +
+ + {c.day} {c.date} + +
+ ))} +
+ + {/* Timeline Body Grid */} +
+ {/* Columns Background */} + {columns.map((_, i) => ( +
+ ))} + + {/* Today Line removed for cleaner mock */} + + {/* Cards */} + {cards.map((card) => { + const x = (card.colStart - 1) * colWidth; + const width = (card.colEnd - card.colStart) * colWidth; + const y = rowOffset + (card.row - 1) * rowHeight; + + return ( +
+ {/* Left Indicator */} +
+ + {/* Text */} +
+ + {card.title} + + + {card.subtitle} + +
+ + {/* Menu */} +
+ +
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/components/landing/HeroSection 2.tsx b/src/components/landing/HeroSection 2.tsx new file mode 100644 index 00000000..5dd32060 --- /dev/null +++ b/src/components/landing/HeroSection 2.tsx @@ -0,0 +1,330 @@ +import { useEffect, useRef, useState } from "react"; +import { motion, useAnimation } from "framer-motion"; + +import { LANDING_META } from "@/constants/landing"; + +import { staggerContainer, staggerItem } from "@/hooks/useScrollAnimation"; + +// ─── 숫자 카운터 훅 ────────────────────────────────────────────────────────── +function useCounter(target: number, duration = 1600, delay = 0) { + const [count, setCount] = useState(0); + const rafRef = useRef(0); + + useEffect(() => { + const timer = setTimeout(() => { + const startTime = performance.now(); + const tick = (now: number) => { + const progress = Math.min((now - startTime) / duration, 1); + const eased = 1 - Math.pow(1 - progress, 4); // easeOutQuart + setCount(Math.floor(eased * target)); + if (progress < 1) rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + }, delay); + + return () => { + clearTimeout(timer); + cancelAnimationFrame(rafRef.current); + }; + }, [target, duration, delay]); + + return count; +} + +// ─── 채널 카드 ─────────────────────────────────────────────────────────────── +interface IChannelCardProps { + channel: string; + metric: string; + target: number; + color: string; + dot: string; + delay: number; +} + +function ChannelCard({ + channel, + metric, + target, + color, + dot, + delay, +}: IChannelCardProps) { + // 목업 진입(0.3s delay + 0.8s duration) 이후 시작 + const count = useCounter(target, 1600, 1100 + delay); + + return ( +
+
+ + + {channel} + +
+

{metric}

+

+ {count.toLocaleString()} +

+
+ +
+
+ ); +} + +// ─── 대시보드 목업 ──────────────────────────────────────────────────────────── +const CHANNEL_CARDS: IChannelCardProps[] = [ + { + channel: "Google Ads", + metric: "오늘 클릭수", + target: 12847, + color: "bg-blue-600/30 border-blue-500/30", + dot: "bg-blue-400", + delay: 0, + }, + { + channel: "Meta", + metric: "노출 횟수", + target: 98532, + color: "bg-purple-600/30 border-purple-500/30", + dot: "bg-purple-400", + delay: 150, + }, + { + channel: "Naver", + metric: "전환 수", + target: 3241, + color: "bg-emerald-600/30 border-emerald-500/30", + dot: "bg-emerald-400", + delay: 300, + }, +]; + +function DashboardMockup() { + return ( +
+ {/* 브라우저 크롬 바 */} +
+ + + +
+ app.whereyouad.com/dashboard +
+
+ +
+ {/* 상단 레이블 */} +
+

통합 성과 개요

+ + + LIVE + +
+ + {/* 채널 카드 */} +
+ {CHANNEL_CARDS.map((card) => ( + + ))} +
+ + {/* 미니 SVG 차트 */} +
+

클릭 추이 (최근 24h)

+ + + + + + + + + + +
+
+
+ ); +} + +// ─── HeroSection ───────────────────────────────────────────────────────────── +export default function HeroSection() { + const textControls = useAnimation(); + const mockupControls = useAnimation(); + + useEffect(() => { + const run = async () => { + await textControls.start("visible"); + mockupControls.start("visible"); + }; + run(); + }, [textControls, mockupControls]); + + return ( +
+ {/* 배경 도트 패턴 */} +
+ {/* 배경 글로우 블롭 */} +
+ + {/* ── 텍스트 블록 ── */} + + {/* 뱃지 */} + + + + 실시간 광고 모니터링 플랫폼 + + + + {/* 헤드라인 */} + + {LANDING_META.tagline} + + + {/* 서브 카피 */} + + {LANDING_META.subCopy.split("\n").map((line, i) => ( + + {line} + {i < LANDING_META.subCopy.split("\n").length - 1 &&
} +
+ ))} +
+ + {/* CTA 버튼 */} + + + 무료로 시작하기 + + + + 데모 보기 + + +
+ + {/* ── 대시보드 목업 ── */} + + {/* float 루프 */} + + + + + {/* 하단 페이드 아웃 */} +
+ + + {/* ── 스크롤 인디케이터 ── */} + + + Scroll + + + + + +
+ ); +} diff --git a/src/components/landing/HowItWorksSection 2.tsx b/src/components/landing/HowItWorksSection 2.tsx new file mode 100644 index 00000000..174b37bb --- /dev/null +++ b/src/components/landing/HowItWorksSection 2.tsx @@ -0,0 +1,282 @@ +import { useEffect, useRef, useState } from "react"; +import { useInView } from "react-intersection-observer"; +import type { Variants } from "framer-motion"; +import { motion, useAnimation } from "framer-motion"; + +import { HOW_IT_WORKS_STEPS } from "@/constants/landing"; + +import { staggerContainer } from "@/hooks/useScrollAnimation"; + +// ─── 스텝별 아이콘 ──────────────────────────────────────────────────────────── +const STEP_ICONS = [ + // 광고 채널 연결 — 플러그/링크 + ({ active }: { active: boolean }) => ( + + + + + ), + // 트래킹 URL 발급 — 코드/슬래시 + ({ active }: { active: boolean }) => ( + + + + + ), + // 클릭 이벤트 수집 — 번개 + ({ active }: { active: boolean }) => ( + + + + ), + // 통합 모니터링 — 바 차트 + ({ active }: { active: boolean }) => ( + + + + + + + ), +] as const; + +// ─── 점선 연결선 ────────────────────────────────────────────────────────────── +function ConnectorLine({ animate: shouldAnimate }: { animate: boolean }) { + return ( +
+ + + {/* 화살표 머리 */} + + +
+ ); +} + +// ─── 스텝 카드 ──────────────────────────────────────────────────────────────── +interface IStepCardProps { + step: (typeof HOW_IT_WORKS_STEPS)[number]; + index: number; + active: boolean; +} + +const stepVariant: Variants = { + hidden: { opacity: 0, x: -24 }, + visible: (i: number) => ({ + opacity: 1, + x: 0, + transition: { duration: 0.5, ease: "easeOut" as const, delay: i * 0.2 }, + }), +}; + +function StepCard({ step, index, active }: IStepCardProps) { + const Icon = STEP_ICONS[index]; + + return ( + + {/* 아이콘 + 번호 원 */} +
+ + + + {/* 번호 뱃지 */} + + {String(index + 1).padStart(2, "0")} + +
+ + {/* 텍스트 */} +
+

+ {step.title} +

+

+ {step.description} +

+
+
+ ); +} + +// ─── HowItWorksSection ──────────────────────────────────────────────────────── +export default function HowItWorksSection() { + const controls = useAnimation(); + const [sectionRef, inView] = useInView({ threshold: 0.2, triggerOnce: true }); + const [activeStep, setActiveStep] = useState(0); + const intervalRef = useRef>(null); + + useEffect(() => { + if (inView) { + controls.start("visible"); + // 뷰포트 진입 후 스텝 순차 하이라이트 + intervalRef.current = setInterval(() => { + setActiveStep((prev) => (prev + 1) % HOW_IT_WORKS_STEPS.length); + }, 2200); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [inView, controls]); + + return ( +
+ {/* 배경 */} +
+ +
+ {/* 섹션 헤더 */} +
+ + How it works + + + 3분이면 시작할 수 있어요 + + + 복잡한 설정 없이, 채널 연결만 하면 데이터가 바로 흐릅니다. + +
+ + {/* 스텝 + 연결선 */} + + {HOW_IT_WORKS_STEPS.map((step, i) => ( +
+
setActiveStep(i)} + style={{ cursor: "pointer" }} + > + +
+ {i < HOW_IT_WORKS_STEPS.length - 1 && ( + i} /> + )} +
+ ))} +
+ + {/* 진행 바 */} +
+ {HOW_IT_WORKS_STEPS.map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/landing/LandingFAQ 2.tsx b/src/components/landing/LandingFAQ 2.tsx new file mode 100644 index 00000000..517d339a --- /dev/null +++ b/src/components/landing/LandingFAQ 2.tsx @@ -0,0 +1,61 @@ +import LandingSectionHeader from "@/components/landing/LandingSectionHeader"; + +import ChevronDown from "@/assets/icon/chevron/chevron-down.svg?react"; +import ChevronUp from "@/assets/icon/chevron/chevron-up.svg?react"; + +type TFAQItem = { q: string; a: string }; + +const FAQ_ITEMS: TFAQItem[] = [ + { + q: "무료로 시작할 수 있나요?", + a: "네. 스타터 플랜으로 주요 기능을 바로 체험할 수 있습니다.", + }, + { + q: "어떤 광고 채널을 연동할 수 있나요?", + a: "Google, Meta, 카카오, 네이버 등 주요 채널을 지원합니다. (추가 채널은 순차 확대 예정입니다.)", + }, + { + q: "요금은 언제부터 결제되나요?", + a: "프로 플랜은 무료 체험 이후에 결제가 시작됩니다. 자세한 내용은 요금제에서 확인하세요.", + }, + { + q: "엔터프라이즈는 어떤 기능이 포함되나요?", + a: "조직/권한 관리, 보안 옵션, 전용 지원 등 규모에 맞춘 기능을 제공합니다.", + }, + { + q: "데모를 받을 수 있나요?", + a: "네. 엔터프라이즈 문의를 통해 데모 일정을 조율할 수 있습니다.", + }, +]; + +export default function LandingFAQ() { + return ( +
+
+
+ +
+ +
+ {FAQ_ITEMS.map(({ q, a }) => ( +
+ + {q} + + + + + +
+ {a} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/landing/LandingFeatures 2.tsx b/src/components/landing/LandingFeatures 2.tsx new file mode 100644 index 00000000..b4dcfee9 --- /dev/null +++ b/src/components/landing/LandingFeatures 2.tsx @@ -0,0 +1,256 @@ +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { motion, useInView } from "framer-motion"; + +import LandingSectionHeader from "@/components/landing/LandingSectionHeader"; + +import SparkleIcon from "@/assets/icon/ai/sparkle.svg?react"; +import ChevronRight from "@/assets/icon/chevron/chevron-right.svg?react"; +import ChevronUp from "@/assets/icon/chevron/chevron-up.svg?react"; +import UserIcon from "@/assets/icon/common/user.svg?react"; +import GoogleAdsLogo from "@/assets/logo/social-logo/circle/googleAds-circle.svg?react"; +import KakaoLogo from "@/assets/logo/social-logo/circle/kakao-circle.svg?react"; +import NaverLogo from "@/assets/logo/social-logo/circle/naver-circle.svg?react"; + +type TFeatureCardProps = { + delay: number; + title: string; + description: string; + Graphic: () => ReactNode; +}; + +function IntegrationGraphic() { + return ( + + +
+ +
+ Naver +
+
+
+ +
+
+ + +
+
+ +
+
+ + Google Ads + +
+
+
+ +
+
+ + +
+ +
+ Kakao +
+
+
+ +
+
+
+ ); +} + +function WorkflowGraphic() { + return ( +
+
+ + + AI로 요약하기 + + +
+
+ 다운로드 +
+
+ ); +} + +function WorkspaceGraphic() { + const fullText = "WhereYouAd@email.com"; + const [typedText, setTypedText] = useState(""); + const [showCursor, setShowCursor] = useState(false); + const containerRef = useRef(null); + const isInView = useInView(containerRef, { once: true, amount: 0.45 }); + + useEffect(() => { + if (!isInView) return; + + setShowCursor(true); + let index = 0; + const timer = setInterval(() => { + index += 1; + setTypedText(fullText.slice(0, index)); + if (index >= fullText.length) { + clearInterval(timer); + setShowCursor(false); + } + }, 40); + + return () => clearInterval(timer); + }, [isInView]); + + return ( +
+
+
+ +
+
+
+ You + + Team JSON + +
+

+ 새로운 멤버를 초대하세요! +

+
+
+ +
+
+ {typedText} +
+ {showCursor && ( + | + )} +
+ +
+
+
+ ); +} + +export default function LandingFeatures() { + const featureCards: Omit[] = [ + { + title: "광고 매체 연동", + description: + "클릭 한 번으로 모든 광고 매체와 마테크 서비스의 데이터를 간편하게 연동하세요.", + Graphic: IntegrationGraphic, + }, + { + title: "AI 기반 워크플로우 제안", + description: + "데이터를 바탕으로 최적의 다음 액션과 워크플로우를 자동으로 제안받으세요.", + Graphic: WorkflowGraphic, + }, + { + title: "조직을 이어주는 워크스페이스", + description: + "에이전시 및 팀원들과 캠페인 맥락을 잃지 않고 한 곳에서 효율적으로 소통하세요.", + Graphic: WorkspaceGraphic, + }, + ]; + + return ( +
+
+ + + + +
+ {featureCards.map(({ title, description, Graphic }, idx) => ( + +
+ +
+

+ {title} +

+

+ {description} +

+
+ ))} +
+
+
+ ); +} diff --git a/src/components/landing/LandingFooter 2.tsx b/src/components/landing/LandingFooter 2.tsx new file mode 100644 index 00000000..8de23428 --- /dev/null +++ b/src/components/landing/LandingFooter 2.tsx @@ -0,0 +1,44 @@ +export default function LandingFooter() { + return ( + + ); +} diff --git a/src/components/landing/LandingGuide 2.tsx b/src/components/landing/LandingGuide 2.tsx new file mode 100644 index 00000000..47ee5a9b --- /dev/null +++ b/src/components/landing/LandingGuide 2.tsx @@ -0,0 +1,218 @@ +import { motion } from "framer-motion"; + +import GuideOverviewChart from "@/components/landing/GuideOverviewChart"; +import GuidePlatform from "@/components/landing/GuidePlatform"; +import GuideTimeline from "@/components/landing/GuideTimeline"; +import LandingSectionHeader from "@/components/landing/LandingSectionHeader"; + +import TimelineDashboard from "@/assets/mockup/optimized/timeline_dashboard.jpg"; + +type TGuideStep = { step: number; title: string; text: string }; + +type TGuidePage = { + number: string; + label: string; + title: string; + description: string; + steps: TGuideStep[]; + image?: string; + alt?: string; + reverse: boolean; + useOverview?: boolean; + useTimeline?: boolean; + usePlatform?: boolean; +}; + +const pages: TGuidePage[] = [ + { + number: "01", + label: "통합 대시보드", + title: "광고 현황을 한눈에 파악하세요", + description: `주요 KPI부터 채널별 성과, 실시간 알림까지 +모든 정보를 하나의 화면에서 확인할 수 있습니다.`, + steps: [ + { + step: 1, + title: "핵심 지표를 즉시 확인", + text: "상단 요약 카드에서 노출수·클릭수·전환율 등 핵심 KPI를 빠르게 확인합니다.", + }, + { + step: 2, + title: "채널별 효율 비교", + text: "채널별 성과 차트로 매체 간 효율을 비교하고 예산 재배분을 결정합니다.", + }, + ], + useOverview: true, + reverse: false, + }, + { + number: "02", + label: "매체 통합 관리", + title: "플랫폼별 캠페인을 한 곳에서 관리하세요", + description: `다수의 광고 매체를 별도 로그인 없이 통합 관리하고 +캠페인 설정부터 소재 심사까지 원스톱으로 처리합니다.`, + steps: [ + { + step: 1, + title: "플랫폼 선택 후 현황 조회", + text: "매체사 목록에서 플랫폼을 선택해 캠페인 전체 현황을 확인합니다.", + }, + { + step: 2, + title: "인라인으로 빠르게 편집", + text: "예산·기간·타겟 설정을 인라인 편집으로 빠르게 수정하고 저장합니다.", + }, + ], + usePlatform: true, + reverse: true, + }, + { + number: "03", + label: "일정 관리 타임라인", + title: "타임라인으로 캠페인 일정을 계획하세요", + description: `간트 차트 방식으로 전체 캠페인 기간을 시각화하고 +기간별 성과를 비교합니다.`, + steps: [ + { + step: 1, + title: "캠페인을 한눈에 파악", + text: "타임라인 뷰에서 캠페인 일정을 주·월 단위로 한눈에 확인합니다.", + }, + { + step: 2, + title: "기간별 성과 세부 확인", + text: "타임라인 바를 클릭하면 해당 기간의 클릭수, 전환 등 세부 성과 지표를 확인할 수 있습니다.", + }, + ], + image: TimelineDashboard, + alt: "Timeline 대시보드 화면", + useTimeline: true, + reverse: false, + }, +]; + +export default function LandingGuide() { + return ( +
+
+
+ + + + +
+ {pages.map((page) => ( + + + {page.useOverview ? ( +
+ +
+ ) : page.useTimeline ? ( +
+ +
+ ) : page.usePlatform ? ( +
+ +
+ ) : ( + {page.alt} + )} +
+ + +
+ + {page.number} + + + {page.label} + +
+ +

+ {page.title} +

+ +

+ {page.description} +

+ +
+ {page.steps.map((item) => ( +
+
+ + {item.title} + +
+

+ {item.text} +

+
+ ))} +
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/landing/LandingHeader 2.tsx b/src/components/landing/LandingHeader 2.tsx new file mode 100644 index 00000000..5a1d5008 --- /dev/null +++ b/src/components/landing/LandingHeader 2.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import logoSvg from "@/assets/logo/service-logo/logo.svg"; + +const navItems = [ + { label: "기능", targetId: "features" }, + { label: "이용방법", targetId: "guide" }, + { label: "요금제", targetId: "pricing" }, +]; + +function scrollToSection(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth" }); +} + +export default function LandingHeader() { + const [isScrolled, setIsScrolled] = useState(false); + + useEffect(() => { + let rafId: number | null = null; + + function update() { + rafId = null; + const next = window.scrollY > 8; + setIsScrolled((prev) => (prev === next ? prev : next)); + } + + function onScroll() { + if (rafId != null) return; + rafId = window.requestAnimationFrame(update); + } + + update(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + if (rafId != null) window.cancelAnimationFrame(rafId); + window.removeEventListener("scroll", onScroll); + }; + }, []); + + return ( +
+
+ + + +
+ + + +
+ + 로그인 + + + 회원가입 + +
+
+ ); +} diff --git a/src/components/landing/LandingHero 2.tsx b/src/components/landing/LandingHero 2.tsx new file mode 100644 index 00000000..ebfc59e2 --- /dev/null +++ b/src/components/landing/LandingHero 2.tsx @@ -0,0 +1,60 @@ +import { motion } from "framer-motion"; + +import ChevronDown from "@/assets/icon/chevron/chevron-down.svg?react"; +import MockupTestImage from "@/assets/mockup/optimized/mockup_test.jpg"; + +export default function LandingHero() { + return ( +
+ +
+ +
+ + WhereYouAd + + + 광고 통합 대시보드 + +
+

+ 광고 성과를 실시간으로{"\n"}한 화면에서 관리하세요 +

+

+ Google·Meta 파트너 서비스로 광고 데이터를 한 곳에서 관리하세요. +

+
+ + +
+ ); +} diff --git a/src/components/landing/LandingMultiDevice 2.tsx b/src/components/landing/LandingMultiDevice 2.tsx new file mode 100644 index 00000000..e2bd4f43 --- /dev/null +++ b/src/components/landing/LandingMultiDevice 2.tsx @@ -0,0 +1,62 @@ +import IosAppDockImage from "@/assets/mockup/iOS app dock.png"; +import IpadMockupImage from "@/assets/mockup/iPad Air mockup.png"; +import LaptopMockupImage from "@/assets/mockup/laptop_mockup.png"; + +export default function LandingMultiDevice() { + return ( +
+
+
+

Multi-device support

+

+ 모바일·태블릿·데스크탑에서 모두 사용 가능 +

+
+ +
+
+ 모바일에서 WhereYouAd를 사용하는 화면 +
+
+

Mobile

+
+
+ +
+ 태블릿에서 WhereYouAd를 사용하는 화면 +
+
+

Tablet

+
+
+ +
+ 노트북에서 WhereYouAd를 사용하는 화면 +
+
+

Desktop

+
+
+
+
+
+ ); +} diff --git a/src/components/landing/LandingNav 2.tsx b/src/components/landing/LandingNav 2.tsx new file mode 100644 index 00000000..af967e1d --- /dev/null +++ b/src/components/landing/LandingNav 2.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { motion, useReducedMotion, useScroll } from "framer-motion"; + +export default function LandingNav() { + const [scrolled, setScrolled] = useState(false); + const { scrollY } = useScroll(); + const prefersReduced = useReducedMotion(); + + useEffect(() => { + return scrollY.on("change", (y) => setScrolled(y > 50)); + }, [scrollY]); + + return ( + +
+ {/* 로고 */} + + Where you ad + + + {/* 네비게이션 링크 */} + + + {/* 우측 CTA */} +
+ + 로그인 + + + 무료 시작 + +
+
+
+ ); +} diff --git a/src/components/landing/LandingPricing 2.tsx b/src/components/landing/LandingPricing 2.tsx new file mode 100644 index 00000000..9d62d2c2 --- /dev/null +++ b/src/components/landing/LandingPricing 2.tsx @@ -0,0 +1,233 @@ +import { useNavigate } from "react-router-dom"; +import { motion } from "framer-motion"; + +import LandingSectionHeader from "@/components/landing/LandingSectionHeader"; + +type TFeature = { text: string; enabled: boolean }; + +type TPlan = { + name: string; + target: string; + price: string; + priceUnit?: string; + priceSubText?: string; + buttonText: string; + featured: boolean; + assurance: string; + features: TFeature[]; +}; + +const plans: TPlan[] = [ + { + name: "프리", + target: "서비스를 처음 체험하는 누구나", + price: "무료", + buttonText: "무료로 시작하기", + featured: false, + assurance: "카드 불필요", + features: [ + { text: "광고 매체 연동 최대 3개", enabled: true }, + { text: "멤버 1명", enabled: true }, + { text: "단일 워크스페이스", enabled: true }, + { text: "AI 성과 리포트", enabled: false }, + { text: "분석보고서 이메일 전송", enabled: false }, + ], + }, + { + name: "스타터", + target: "1인 창업자", + price: "₩30,000", + priceUnit: "/월", + priceSubText: "(부가세 별도)", + buttonText: "시작하기", + featured: false, + assurance: "카드 필요", + features: [ + { text: "광고 매체 연동 최대 10개", enabled: true }, + { text: "멤버 1명", enabled: true }, + { text: "워크스페이스 최대 5개", enabled: true }, + { text: "AI 요약 기능", enabled: true }, + { text: "분석보고서 이메일 전송", enabled: false }, + ], + }, + { + name: "팀", + target: "소규모 팀 (2~10인)", + price: "₩150,000", + priceUnit: "/월", + priceSubText: "(부가세 별도)", + buttonText: "14일 무료 체험하기", + featured: true, + assurance: "무료 체험 가능 · 카드 필요", + features: [ + { text: "광고 매체 연동 최대 30개", enabled: true }, + { text: "멤버 최대 10명", enabled: true }, + { text: "워크스페이스 최대 10개", enabled: true }, + { text: "AI 요약 기능", enabled: true }, + { text: "분석보고서 이메일 전송", enabled: true }, + ], + }, + { + name: "프로", + target: "성장 기업 및 에이전시", + price: "₩500,000", + priceUnit: "/월", + priceSubText: "(부가세 별도)", + buttonText: "영업팀에 문의", + featured: false, + assurance: "맞춤 견적 · 데모 제공", + features: [ + { text: "광고 매체 연동 무제한", enabled: true }, + { text: "멤버 무제한", enabled: true }, + { text: "워크스페이스 무제한", enabled: true }, + { text: "광고 성과 변화 알림 및 실시간 대응", enabled: true }, + { text: "전담 어카운트 매니저(AM)", enabled: true }, + ], + }, +]; + +function CheckIcon({ enabled }: { enabled: boolean }) { + return ( + + + + ); +} + +export default function LandingPricing() { + const navigate = useNavigate(); + + function handleCta(planName: string) { + if (planName === "프로") { + const subject = encodeURIComponent("WhereYouAd 요금제 문의"); + const body = encodeURIComponent("문의하실 내용을 입력해 주세요."); + window.location.href = `mailto:whereyouadofficial@gmail.com?subject=${subject}&body=${body}`; + return; + } + + navigate("/signup", { replace: false }); + } + + return ( +
+
+
+ + + + +
+ {plans.map((plan, i) => ( + + {plan.featured && ( +
+ 가장 인기 +
+ )} + +

+ {plan.name} +

+

+ {plan.target} +

+ +
+
+ + {plan.price} + + {plan.priceUnit && ( + + {plan.priceUnit} + + )} +
+ {plan.priceUnit && plan.priceSubText && ( +
+ {plan.priceSubText} +
+ )} +
+ + + +

{plan.assurance}

+ +
+ +
    + {plan.features.map((feature) => ( +
  • + + + {feature.text} + +
  • + ))} +
+ + ))} +
+
+
+ ); +} diff --git a/src/components/landing/LandingSectionHeader 2.tsx b/src/components/landing/LandingSectionHeader 2.tsx new file mode 100644 index 00000000..1c3354fa --- /dev/null +++ b/src/components/landing/LandingSectionHeader 2.tsx @@ -0,0 +1,28 @@ +type TAlign = "left" | "center"; + +export default function LandingSectionHeader({ + title, + subtitle, + align = "center", + className = "", +}: { + title: string; + subtitle?: string; + align?: TAlign; + className?: string; +}) { + const alignClass = align === "left" ? "text-left" : "text-center"; + + return ( +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ); +} diff --git a/src/components/landing/ProblemSection 2.tsx b/src/components/landing/ProblemSection 2.tsx new file mode 100644 index 00000000..17eae897 --- /dev/null +++ b/src/components/landing/ProblemSection 2.tsx @@ -0,0 +1,185 @@ +import { motion } from "framer-motion"; + +import { PROBLEMS } from "@/constants/landing"; + +import { useScrollAnimation } from "@/hooks/useScrollAnimation"; + +// ─── 문제별 아이콘 ──────────────────────────────────────────────────────────── +const PROBLEM_ICONS = [ + // 채널마다 따로 접속 — 분리된 창 아이콘 + ({ className }: { className?: string }) => ( + + + + + + + ), + // 이슈를 너무 늦게 발견 — 알람 + 느낌표 아이콘 + ({ className }: { className?: string }) => ( + + + + + + + ), + // 데이터가 파편화 — 흩어진 점들 아이콘 + ({ className }: { className?: string }) => ( + + + + + + + + + + + + + + ), +] as const; + +const CARD_ACCENT = [ + { + border: "border-blue-500/20 hover:border-blue-500/60", + icon: "text-blue-400 bg-blue-500/10", + glow: "group-hover:shadow-blue-500/10", + badge: "text-blue-400 bg-blue-500/10", + }, + { + border: "border-orange-500/20 hover:border-orange-500/60", + icon: "text-orange-400 bg-orange-500/10", + glow: "group-hover:shadow-orange-500/10", + badge: "text-orange-400 bg-orange-500/10", + }, + { + border: "border-rose-500/20 hover:border-rose-500/60", + icon: "text-rose-400 bg-rose-500/10", + glow: "group-hover:shadow-rose-500/10", + badge: "text-rose-400 bg-rose-500/10", + }, +] as const; + +// ─── ProblemSection ─────────────────────────────────────────────────────────── +export default function ProblemSection() { + const { ref, controls, staggerContainer, staggerItem } = useScrollAnimation(); + + return ( +
+ {/* 배경 그라디언트 */} +
+ +
+ {/* 섹션 헤더 */} + + + Pain Points + + + 광고팀이 매일 겪는 문제들 + + + 채널이 늘어날수록 관리 비용은 기하급수적으로 증가합니다. + + + + {/* 문제 카드 그리드 */} + + {PROBLEMS.map((problem, i) => { + const Icon = PROBLEM_ICONS[i]; + const accent = CARD_ACCENT[i]; + + return ( + + {/* 호버 시 배경 글로우 */} +
+ + {/* 아이콘 */} +
+ +
+ + {/* 텍스트 */} +

+ {problem.title} +

+

+ {problem.description} +

+ + {/* 우하단 번호 */} + + 0{i + 1} + + + ); + })} + +
+
+ ); +} diff --git a/src/components/landing/SocialProofSection 2.tsx b/src/components/landing/SocialProofSection 2.tsx new file mode 100644 index 00000000..fae0ab36 --- /dev/null +++ b/src/components/landing/SocialProofSection 2.tsx @@ -0,0 +1,3 @@ +export default function SocialProofSection() { + return <>; +} diff --git a/src/components/setting/PasswordSection 2.tsx b/src/components/setting/PasswordSection 2.tsx new file mode 100644 index 00000000..9e55a403 --- /dev/null +++ b/src/components/setting/PasswordSection 2.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; + +import Input from "../common/input/Input"; + +import EyeIcon from "@/assets/icon/common/eye.svg?react"; +import EyeOffIcon from "@/assets/icon/common/eye-off.svg?react"; +import LockIcon from "@/assets/icon/common/lock.svg?react"; + +type TPasswordSectionProps = { + currentPassword: string; + setCurrentPassword: (v: string) => void; + newPassword: string; + setNewPassword: (v: string) => void; + confirmNewPassword: string; + setConfirmNewPassword: (v: string) => void; + errors: { + currentPassword: string; + newPassword: string; + confirmNewPassword: string; + }; +}; + +export default function PasswordSection({ + currentPassword, + setCurrentPassword, + newPassword, + setNewPassword, + confirmNewPassword, + setConfirmNewPassword, + errors, +}: TPasswordSectionProps) { + const [showCurrent, setShowCurrent] = useState(false); + const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + return ( +
+
+
+
+ +

+ 비밀번호 변경 +

+
+ +

+ 안전한 계정 사용을 위해 주기적인 비밀번호 변경을 권장합니다 +
영문, 숫자, 특수문자가 모두 포함된 8~16자리의 비밀번호를 + 설정해 주세요 +

+
+
+
+
+ setCurrentPassword(e.target.value)} + error={!!errors.currentPassword} + helperText={errors.currentPassword} + rightElement={ + + } + /> +
+ + setNewPassword(e.target.value)} + error={!!errors.newPassword} + helperText={errors.newPassword} + rightElement={ + + } + /> + setConfirmNewPassword(e.target.value)} + error={!!errors.confirmNewPassword} + helperText={errors.confirmNewPassword} + rightElement={ + + } + /> +
+
+ ); +} diff --git a/src/components/setting/ProfileSection 2.tsx b/src/components/setting/ProfileSection 2.tsx new file mode 100644 index 00000000..99118312 --- /dev/null +++ b/src/components/setting/ProfileSection 2.tsx @@ -0,0 +1,153 @@ +import type React from "react"; + +import Button from "../common/button/Button"; +import Input from "../common/input/Input"; + +import CameraIcon from "@/assets/icon/common/camera.svg?react"; +import CheckIcon from "@/assets/icon/common/check.svg?react"; +import UserProfileCircleIcon from "@/assets/icon/common/userProfileCircle.svg?react"; + +type TProfileSectionProps = { + name: string; + setName: (v: string) => void; + organizations: { name: string; position: string }[]; + email: string; + phoneNumber: string; + fileRef: React.RefObject; + preview: string | null; + openFilePicker: () => void; + onPickFile: (e: React.ChangeEvent) => void; + resetImage: () => void; +}; +export default function ProfileSection({ + name, + setName, + organizations, + email, + phoneNumber, + fileRef, + preview, + onPickFile, + openFilePicker, + resetImage, +}: TProfileSectionProps) { + return ( +
+
+
+ +

+ 프로필 +

+
+
+
+
+
+ 프로필 이미지 +
+ +
+ {preview ? ( + 프로필 이미지 미리보기 + ) : ( + + )} +
+
+ + +
+
+
+
+ setName(e.target.value)} + /> +
+
+
소속 조직
+ {organizations.length === 0 ? ( +
+ 소속된 조직이 없습니다. +
+ ) : ( + <> +
+ {organizations.map((org, idx) => ( +
+ +
+ ))} + +
+ 조직 정보는 별도 조직페이지에서 수정할 수 있습니다. +
+
+ + )} +
+
+ } + readOnly + /> +
+ 이메일은 변경할 수 없습니다. +
+
+
+ } + readOnly + /> +
+ 전화번호는 변경할 수 없습니다. +
+
+
+
+
+ ); +} diff --git a/src/constants/landing 2.ts b/src/constants/landing 2.ts new file mode 100644 index 00000000..0a04f89d --- /dev/null +++ b/src/constants/landing 2.ts @@ -0,0 +1,88 @@ +export const LANDING_META = { + serviceName: "Where you ad", + tagline: "흩어진 광고 채널, 하나의 화면으로", + subCopy: + "인스타그램, 구글, 네이버, 카카오 — 모든 광고 성과를\n실시간으로 한 대시보드에서 모니터링하세요", +} as const; + +export const PROBLEMS = [ + { + id: 1, + title: "채널마다 따로 접속", + description: "각 플랫폼 대시보드를 오가며 시간 낭비", + }, + { + id: 2, + title: "이슈를 너무 늦게 발견", + description: "클릭 급증, 예산 초과를 뒤늦게 파악", + }, + { + id: 3, + title: "데이터가 파편화", + description: "집계 단위가 달라 채널 간 비교가 불가", + }, +] as const; + +export const FEATURES = [ + { + id: 1, + title: "실시간 통합 대시보드", + description: "1분/5분/1시간 단위 차트로 전 채널 성과를 한눈에", + }, + { + id: 2, + title: "트래킹 링크 생성", + description: "클릭 이벤트 자동 수집으로 정확한 전환 추적", + }, + { + id: 3, + title: "가상 시뮬레이터", + description: "실제 과금 없이 광고 전략을 미리 테스트", + }, + { + id: 4, + title: "AI 인사이트", + description: "LLM 기반 예산 추천 & 자연어 요약으로 빠른 의사결정", + }, +] as const; + +export const HOW_IT_WORKS_STEPS = [ + { + step: 1, + title: "광고 채널 연결", + description: "Google / Meta / Naver / Kakao 계정을 한 번에 연동", + }, + { + step: 2, + title: "트래킹 URL 발급 & 광고에 등록", + description: "자동 생성된 트래킹 링크를 광고 소재에 삽입", + }, + { + step: 3, + title: "실시간 클릭 이벤트 자동 수집", + description: "방문자 행동 데이터가 즉시 수집·처리", + }, + { + step: 4, + title: "대시보드에서 통합 성과 모니터링", + description: "모든 채널 지표를 단일 화면에서 비교·분석", + }, +] as const; + +export const SOCIAL_PROOF_STATS = [ + { + id: 1, + label: "연동 광고 채널", + value: "4+", + }, + { + id: 2, + label: "실시간 처리", + value: "1,000건/초", + }, + { + id: 3, + label: "대시보드 로딩", + value: "< 1초", + }, +] as const; diff --git a/src/hooks/common/useComingSoon 2.ts b/src/hooks/common/useComingSoon 2.ts new file mode 100644 index 00000000..ee7b5485 --- /dev/null +++ b/src/hooks/common/useComingSoon 2.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; + +const DEFAULT_MESSAGE = + "해당 기능은 아직 준비 중이에요. 곧 이용하실 수 있어요!"; + +export function useComingSoon() { + const showComingSoon = useCallback((message: string = DEFAULT_MESSAGE) => { + toast.info(message, { + id: "coming-soon", + duration: 4000, + }); + }, []); + + return { showComingSoon }; +} diff --git a/src/hooks/common/useImageUploader 2.ts b/src/hooks/common/useImageUploader 2.ts new file mode 100644 index 00000000..951d1903 --- /dev/null +++ b/src/hooks/common/useImageUploader 2.ts @@ -0,0 +1,34 @@ +import type { ChangeEvent } from "react"; +import { useEffect, useRef, useState } from "react"; + +export function useImageUploader() { + const fileRef = useRef(null); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + + const openFilePicker = () => { + fileRef.current?.click(); + }; + const onPickFile = (e: ChangeEvent) => { + const f = e.target.files?.[0]; + if (!f) return; + + setFile(f); + + const url = URL.createObjectURL(f); + setPreview(url); + }; + const resetImage = () => { + if (fileRef.current) { + fileRef.current.value = ""; + } + setFile(null); + setPreview(null); + }; + useEffect(() => { + return () => { + if (preview) URL.revokeObjectURL(preview); + }; + }); + return { fileRef, file, preview, openFilePicker, onPickFile, resetImage }; +} diff --git a/src/hooks/dashboard/useOverviewCampaignList 2.ts b/src/hooks/dashboard/useOverviewCampaignList 2.ts new file mode 100644 index 00000000..57f5fef7 --- /dev/null +++ b/src/hooks/dashboard/useOverviewCampaignList 2.ts @@ -0,0 +1,17 @@ +import type { ICampaign } from "@/types/ads/campaign"; + +import { useCoreQuery } from "@/hooks/customQuery"; + +import { getCampaignList } from "@/api/ads/ads"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +/** 광고 목록과 동일 쿼리 키로 캐시 공유 */ +export function useOverviewCampaignList() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + return useCoreQuery( + ["campaigns", orgId], + () => getCampaignList(orgId!), + { enabled: !!orgId }, + ); +} diff --git a/src/hooks/useScrollAnimation 2.ts b/src/hooks/useScrollAnimation 2.ts new file mode 100644 index 00000000..a857c618 --- /dev/null +++ b/src/hooks/useScrollAnimation 2.ts @@ -0,0 +1,79 @@ +import { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import type { Variants } from "framer-motion"; +import { useAnimation, useReducedMotion } from "framer-motion"; + +export const fadeUp: Variants = { + hidden: { opacity: 0, y: 40 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6, ease: "easeOut" }, + }, +}; + +export const fadeIn: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { duration: 0.6, ease: "easeOut" }, + }, +}; + +export const staggerContainer: Variants = { + hidden: {}, + visible: { + transition: { + staggerChildren: 0.15, + delayChildren: 0.1, + }, + }, +}; + +export const staggerItem: Variants = { + hidden: { opacity: 0, y: 30 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.5, ease: "easeOut" }, + }, +}; + +// reduced-motion: 위치 이동 없이 opacity만 페이드 +const reducedFadeUp: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.3 } }, +}; + +const reducedStaggerItem: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.3 } }, +}; + +const reducedStaggerContainer: Variants = { + hidden: {}, + visible: { transition: { staggerChildren: 0.05 } }, +}; + +export function useScrollAnimation(threshold = 0.15) { + const controls = useAnimation(); + const prefersReduced = useReducedMotion(); + const [ref, inView] = useInView({ threshold, triggerOnce: true }); + + useEffect(() => { + if (inView) { + controls.start("visible"); + } + }, [controls, inView]); + + return { + ref, + controls, + fadeUp: prefersReduced ? reducedFadeUp : fadeUp, + fadeIn, + staggerContainer: prefersReduced + ? reducedStaggerContainer + : staggerContainer, + staggerItem: prefersReduced ? reducedStaggerItem : staggerItem, + }; +} diff --git a/src/pages/LandingPage 2.tsx b/src/pages/LandingPage 2.tsx new file mode 100644 index 00000000..84919308 --- /dev/null +++ b/src/pages/LandingPage 2.tsx @@ -0,0 +1,66 @@ +import { lazy, Suspense } from "react"; + +import HeroSection from "@/components/landing/HeroSection"; +import LandingFooter from "@/components/landing/LandingFooter"; +import LandingNav from "@/components/landing/LandingNav"; + +// 폴드 아래 섹션은 lazy load +const ProblemSection = lazy( + () => import("@/components/landing/ProblemSection"), +); +const FeatureSection = lazy( + () => import("@/components/landing/FeatureSection"), +); +const HowItWorksSection = lazy( + () => import("@/components/landing/HowItWorksSection"), +); +const SocialProofSection = lazy( + () => import("@/components/landing/SocialProofSection"), +); +const CTASection = lazy(() => import("@/components/landing/CTASection")); + +function SectionFallback() { + return ( +
+ ); +} + +export default function LandingPage() { + return ( +
+ + + {/* Hero — 즉시 렌더 */} + + + {/* 폴드 아래 — lazy */} + }> +
+ +
+
+ + }> +
+ +
+
+ + }> +
+ +
+
+ + }> + + + + }> + + + + +
+ ); +} diff --git a/src/pages/dashboard/overview/OverviewAiDrawer 2.tsx b/src/pages/dashboard/overview/OverviewAiDrawer 2.tsx new file mode 100644 index 00000000..c0ac4ea6 --- /dev/null +++ b/src/pages/dashboard/overview/OverviewAiDrawer 2.tsx @@ -0,0 +1,60 @@ +import { lazy, Suspense } from "react"; + +import Drawer from "@/components/common/drawer/Drawer"; + +import DownloadIcon from "@/assets/icon/common/download.svg?react"; +import LinkIcon from "@/assets/icon/common/link.svg?react"; + +const OverviewAiReportPanel = lazy(() => import("./OverviewAiReportPanel")); + +export function OverviewAiDrawer({ + isOpen, + onClose, + onShareLink, + onDownloadPdf, +}: { + isOpen: boolean; + onClose: () => void; + onShareLink: () => void; + onDownloadPdf: () => void; +}) { + return ( + + ), + onClick: onShareLink, + }, + { + label: "PDF로 저장하기", + icon: ( + + ), + onClick: onDownloadPdf, + }, + ]} + > + {isOpen && ( + + + + )} + + ); +} diff --git a/src/pages/dashboard/overview/OverviewBudgetSection 2.tsx b/src/pages/dashboard/overview/OverviewBudgetSection 2.tsx new file mode 100644 index 00000000..75d4c0ed --- /dev/null +++ b/src/pages/dashboard/overview/OverviewBudgetSection 2.tsx @@ -0,0 +1,118 @@ +import type { IApiErrorResponse } from "@/types/common/common"; + +import type { useOverviewBudget } from "@/hooks/dashboard/useOverviewBudget"; + +import Badge from "@/components/common/badge/Badge"; +import Card from "@/components/common/card/Card"; +import ChartLegend, { + type IChartLegendItem, +} from "@/components/common/chart/ChartLegend"; +import { Skeleton } from "@/components/common/skeleton/Skeleton"; +import type { getBudgetStatus } from "@/components/dashboard/charts/BudgetGaugeChart"; +import BudgetGaugeChart, { + statusBadgeVariant, +} from "@/components/dashboard/charts/BudgetGaugeChart"; + +import OverviewCampaignSnapshotCard from "@/pages/dashboard/overview/OverviewCampaignSnapshotCard"; + +const budgetStatusLegendItems: IChartLegendItem[] = [ + { label: "안정", colorClass: "bg-status-green" }, + { label: "주의", colorClass: "bg-status-yellow" }, + { label: "위험", colorClass: "bg-status-red" }, +]; + +function BudgetGaugeSkeleton() { + return ( +
+
+ + + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
+ ); +} + +export function OverviewBudgetSection({ + budget, + isBudgetLoading, + isBudgetError, + budgetError, + budgetStatus, +}: { + budget: ReturnType["data"]; + isBudgetLoading: boolean; + isBudgetError: boolean; + budgetError: IApiErrorResponse | null; + budgetStatus: ReturnType | null; +}) { + return ( +
+
+ + } + RightElement={ + budgetStatus ? ( + + {budgetStatus} + + ) : undefined + } + > +
+ {isBudgetError ? ( +
+ {budgetError?.message ?? ( + <> + 예산 데이터를 불러오지 못했습니다. +
+ 잠시 후 다시 시도해 주세요. + + )} +
+ ) : isBudgetLoading ? ( + + ) : budget ? ( + + ) : null} +
+
+
+ +
+ +
+
+ ); +} diff --git a/src/pages/dashboard/overview/OverviewCampaignSnapshotCard 2.tsx b/src/pages/dashboard/overview/OverviewCampaignSnapshotCard 2.tsx new file mode 100644 index 00000000..693502ff --- /dev/null +++ b/src/pages/dashboard/overview/OverviewCampaignSnapshotCard 2.tsx @@ -0,0 +1,149 @@ +import { memo, useCallback, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { twMerge } from "tailwind-merge"; + +import type { ICampaign } from "@/types/ads/campaign"; + +import { useOverviewCampaignList } from "@/hooks/dashboard/useOverviewCampaignList"; + +import Button from "@/components/common/button/Button"; +import Card from "@/components/common/card/Card"; +import { Skeleton } from "@/components/common/skeleton/Skeleton"; + +import ChevronDoubleRightIcon from "@/assets/icon/chevron/chervon-double-right.svg?react"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +function visibleCampaigns(list: ICampaign[]) { + return list.filter((c) => c.status !== "OVER"); +} + +const platformShort: Record = { + naver: "Naver", + google: "Google", + kakao: "Kakao", +}; + +const SnapshotRow = memo(function SnapshotRow({ + campaign, + onOpen, +}: { + campaign: ICampaign; + onOpen: (projectId: number) => void; +}) { + return ( + + ); +}); + +type TOverviewCampaignSnapshotCardProps = { + className?: string; +}; + +export default memo(function OverviewCampaignSnapshotCard({ + className, +}: TOverviewCampaignSnapshotCardProps) { + const navigate = useNavigate(); + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + const { + data: campaigns = [], + isPending, + isError, + error, + } = useOverviewCampaignList(); + + const { totalVisible, topByBudgetUsage } = useMemo(() => { + const visible = visibleCampaigns(campaigns); + const top = [...visible] + .sort((a, b) => b.budgetUsageRate - a.budgetUsageRate) + .slice(0, 2); + return { + totalVisible: visible.length, + topByBudgetUsage: top, + }; + }, [campaigns]); + + const openCampaign = useCallback( + (projectId: number) => { + if (orgId == null) return; + navigate(`/ads/${orgId}/${projectId}`); + }, + [navigate, orgId], + ); + + if (!orgId) return null; + + return ( + + 예산 소진 상위 + + ) + } + RightElement={ + + } + > +
+ {isError ? ( +

+ {error?.message ?? + "캠페인 목록을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} +

+ ) : isPending ? ( +
+ + +
+ ) : totalVisible === 0 ? ( +

+ 표시할 캠페인이 없습니다. 광고 관리에서 캠페인을 등록해 보세요. +

+ ) : ( +
+
+ {topByBudgetUsage.map((c) => ( + + ))} +
+
+ )} +
+
+ ); +}); diff --git a/src/pages/dashboard/overview/OverviewKpiSection 2.tsx b/src/pages/dashboard/overview/OverviewKpiSection 2.tsx new file mode 100644 index 00000000..b4648d1b --- /dev/null +++ b/src/pages/dashboard/overview/OverviewKpiSection 2.tsx @@ -0,0 +1,85 @@ +import { Suspense } from "react"; + +import type { IApiErrorResponse } from "@/types/common/common"; + +import type { useOverviewMetrics } from "@/hooks/dashboard/useOverviewMetrics"; + +import Card from "@/components/common/card/Card"; +import StatCard from "@/components/common/card/StatCard"; +import ChartLegend from "@/components/common/chart/ChartLegend"; +import { Skeleton } from "@/components/common/skeleton/Skeleton"; +import TrafficChart, { + TrafficChartDownload, +} from "@/components/dashboard/charts/TrafficChart"; + +function KpiSkeletonCard() { + return ( +
+ + + +
+ ); +} + +export function OverviewKpiSection({ + kpis, + isKpisLoading, + isKpisError, + kpisError, +}: { + kpis: ReturnType["data"]; + isKpisLoading: boolean; + isKpisError: boolean; + kpisError: IApiErrorResponse | null; +}) { + const kpiList = kpis ?? []; + + return ( +
+ {isKpisError ? ( +
+ {kpisError?.message ?? + "지표 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} +
+ ) : ( +
+ {isKpisLoading + ? [0, 1, 2, 3].map((i) => ) + : kpiList.map((kpi) => ( + + ))} +
+ )} + + + } + RightElement={} + > +
+ + } + > + + +
+
+
+ ); +} diff --git a/src/pages/dashboard/overview/OverviewPlatformSection 2.tsx b/src/pages/dashboard/overview/OverviewPlatformSection 2.tsx new file mode 100644 index 00000000..8ebfcc06 --- /dev/null +++ b/src/pages/dashboard/overview/OverviewPlatformSection 2.tsx @@ -0,0 +1,103 @@ +import type { IApiErrorResponse } from "@/types/common/common"; + +import type { useOverviewRoasRankings } from "@/hooks/dashboard/useOverviewRoasRankings"; + +import Button from "@/components/common/button/Button"; +import Card from "@/components/common/card/Card"; +import { + Skeleton, + SkeletonCircle, +} from "@/components/common/skeleton/Skeleton"; +import PlatformRoasTable, { + PLATFORM_ROAS_TABLE_COL, +} from "@/components/dashboard/platform/PlatformRoasTable"; + +import ChevronDoubleRightIcon from "@/assets/icon/chevron/chervon-double-right.svg?react"; +import WarnCircleIcon from "@/assets/icon/common/warn-circle.svg?react"; + +function PlatformRankingSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ ))} +
+ ); +} + +export function OverviewPlatformSection({ + rankings, + isRankingsLoading, + isRankingsError, + rankingsError, + onNavigate, +}: { + rankings: ReturnType["data"]; + isRankingsLoading: boolean; + isRankingsError: boolean; + rankingsError: IApiErrorResponse | null; + onNavigate: () => void; +}) { + return ( + + + 플랫폼 대시보드 살펴보기 + + + + } + description={ +
+
+ } + > + {isRankingsError ? ( +
+ {rankingsError?.message ?? + "플랫폼 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} +
+ ) : isRankingsLoading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/pages/dashboard/platform/PlatformDashboard.tsx b/src/pages/dashboard/platform/PlatformDashboard.tsx index 39cc2016..0f9e4af0 100644 --- a/src/pages/dashboard/platform/PlatformDashboard.tsx +++ b/src/pages/dashboard/platform/PlatformDashboard.tsx @@ -1,13 +1,20 @@ import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useOutletContext } from "react-router-dom"; +import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; +import { printAsPdf } from "@/utils/download"; + import Button from "@/components/common/button/Button"; import { DropdownMenu } from "@/components/common/dropdownmenu/DropdownMenu"; import AllPlatformView from "@/components/dashboard/platform/AllPlatformView"; import SinglePlatformView from "@/components/dashboard/platform/SinglePlatformView"; +import { OverviewAiDrawer } from "../overview/OverviewAiDrawer"; + +import SparkleCircleIcon from "@/assets/icon/ai/sparkle-circle.svg?react"; import ChevronDownIcon from "@/assets/icon/chevron/chevron-up.svg?react"; +import AiButtonSvg from "@/assets/logo/service-logo/ai-요약버튼.svg?react"; type TDashboardHeaderContext = { setHeaderRight?: (node: ReactNode | null) => void; @@ -16,6 +23,7 @@ type TDashboardHeaderContext = { export default function PlatformDashboard() { const [selectedPlatform, setSelectedPlatform] = useState("전체"); const [isLoading, setIsLoading] = useState(true); + const [isAiPanelOpen, setIsAiPanelOpen] = useState(false); const { setHeaderRight } = useOutletContext(); const isAllView = selectedPlatform === "전체"; @@ -83,6 +91,23 @@ export default function PlatformDashboard() { } items={platformItems} /> + +
, ); @@ -96,6 +121,19 @@ export default function PlatformDashboard() { ) : ( )} + + {/* AI 요약 패널 */} + setIsAiPanelOpen(false)} + onShareLink={() => { + navigator.clipboard + .writeText(window.location.href) + .then(() => toast.success("링크가 복사되었습니다.")) + .catch(() => toast.error("링크 복사에 실패했습니다.")); + }} + onDownloadPdf={() => printAsPdf("ai-report-printing")} + /> ); } diff --git a/src/pages/dashboard/platform/platformDashboard.mock.ts b/src/pages/dashboard/platform/platformDashboard.mock.ts index 02aa40d3..7cdbbfd2 100644 --- a/src/pages/dashboard/platform/platformDashboard.mock.ts +++ b/src/pages/dashboard/platform/platformDashboard.mock.ts @@ -175,3 +175,66 @@ export const platformDailyPerformanceMock: Record< NAVER: generateRecentData(2000000, 500000, 5000, 200), META: generateRecentData(3000000, 300000, 18000, 300), }; + +// 실시간 트래픽 데이터 추가 + +export interface ITimeSeriesData { + minute: string; // YYYYMMDDHHmm + count: number; +} + +export interface IClickStreamResponse { + timeSeriesData: ITimeSeriesData[]; + mode: "real" | "dummy"; + hasSuspect: boolean; + suspectDetail: { + provider: string; + campaignName: string; + adName: string; + message: string; + timestamp: string; + } | null; +} + +/** 실시간 트래픽 데이터 생성 (최근 60분) */ +const generateRealTimeTraffic = ( + _platform: string, + baseCount: number, +): IClickStreamResponse => { + const timeSeriesData: ITimeSeriesData[] = []; + const now = new Date(); + + for (let i = 59; i >= 0; i--) { + const targetDate = new Date(now.getTime() - i * 60 * 1000); + const minuteStr = + targetDate.getFullYear().toString() + + (targetDate.getMonth() + 1).toString().padStart(2, "0") + + targetDate.getDate().toString().padStart(2, "0") + + targetDate.getHours().toString().padStart(2, "0") + + targetDate.getMinutes().toString().padStart(2, "0"); + + // 시간 흐름에 따른 파동 + 랜덤성 부여 + const wave = Math.sin(targetDate.getTime() / (1000 * 60 * 12)) * 0.4; // 12분 주기의 파동 + const random = (Math.random() - 0.5) * 0.3; // ±15% 랜덤 변동 + const count = Math.max(10, Math.floor(baseCount * (1 + wave + random))); + + timeSeriesData.push({ + minute: minuteStr, + count, + }); + } + + // 이상 징후 + return { + timeSeriesData, + mode: "dummy", + hasSuspect: false, + suspectDetail: null, + }; +}; + +export const platformTrafficMock: Record = { + GOOGLE: generateRealTimeTraffic("GOOGLE", 450), + NAVER: generateRealTimeTraffic("NAVER", 280), + META: generateRealTimeTraffic("META", 620), +}; diff --git a/src/pages/landing/LandingPage 2.tsx b/src/pages/landing/LandingPage 2.tsx new file mode 100644 index 00000000..931c13b1 --- /dev/null +++ b/src/pages/landing/LandingPage 2.tsx @@ -0,0 +1,46 @@ +import { lazy, Suspense } from "react"; + +import LandingFooter from "@/components/landing/LandingFooter"; +import LandingHeader from "@/components/landing/LandingHeader"; +import LandingHero from "@/components/landing/LandingHero"; +import LandingMultiDevice from "@/components/landing/LandingMultiDevice"; + +const LandingFeatures = lazy( + () => import("@/components/landing/LandingFeatures"), +); +const LandingGuide = lazy(() => import("@/components/landing/LandingGuide")); +const LandingPricing = lazy( + () => import("@/components/landing/LandingPricing"), +); +const LandingFAQ = lazy(() => import("@/components/landing/LandingFAQ")); + +function SectionFallback({ height = 96 }: { height?: number }) { + return
; +} + +export default function LandingPage() { + return ( +
+ + +
+ + }> + + + }> + + + }> + + + }> + + +
+ + + +
+ ); +} diff --git a/src/store/useSidebarStore 2.ts b/src/store/useSidebarStore 2.ts new file mode 100644 index 00000000..530093e5 --- /dev/null +++ b/src/store/useSidebarStore 2.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +interface ISidebarState { + isCollapsed: boolean; + setIsCollapsed: (next: boolean) => void; + toggleSidebar: () => void; +} + +const useSidebarStore = create((set) => ({ + isCollapsed: true, + setIsCollapsed: (next) => set({ isCollapsed: next }), + toggleSidebar: () => set((prev) => ({ isCollapsed: !prev.isCollapsed })), +})); + +export default useSidebarStore; diff --git a/src/utils/navigation/mainNavSidebar 2.ts b/src/utils/navigation/mainNavSidebar 2.ts new file mode 100644 index 00000000..7133d4b2 --- /dev/null +++ b/src/utils/navigation/mainNavSidebar 2.ts @@ -0,0 +1,45 @@ +import type { INavItem } from "@/types/navigation/navItem"; +import { mainNav } from "@/constants/sidebarNav"; + +import { isPathMatch, normalizePathname } from "./pathMatch"; + +function createMainNavSidebarHelpers() { + const childIdToParentId = new Map(); + const childPathToParentId = new Map(); + + for (const parent of mainNav) { + for (const child of parent.children ?? []) { + childIdToParentId.set(child.id, parent.id); + if (child.path) { + childPathToParentId.set(normalizePathname(child.path), parent.id); + } + } + } + + const childPathEntries = Array.from(childPathToParentId.entries()).sort( + ([a], [b]) => b.length - a.length, + ); + + function resolveParentId(pathname: string): string | undefined { + return ( + childPathToParentId.get(pathname) ?? + childPathEntries.find(([p]) => isPathMatch(pathname, p))?.[1] + ); + } + + function getItemActiveState(pathname: string, item: INavItem) { + const isChildActive = + item.children?.some((c) => + c.path ? isPathMatch(pathname, c.path) : false, + ) ?? false; + + const isParentActive = + (item.path ? isPathMatch(pathname, item.path) : false) || isChildActive; + + return { isChildActive, isParentActive }; + } + + return { childIdToParentId, resolveParentId, getItemActiveState }; +} + +export const mainNavSidebar = createMainNavSidebarHelpers(); diff --git a/src/utils/navigation/pathMatch 2.ts b/src/utils/navigation/pathMatch 2.ts new file mode 100644 index 00000000..ec5df745 --- /dev/null +++ b/src/utils/navigation/pathMatch 2.ts @@ -0,0 +1,5 @@ +export const normalizePathname = (pathname: string) => + pathname.replace(/\/+$/, "") || "/"; + +export const isPathMatch = (currentPath: string, targetPath: string) => + currentPath === targetPath || currentPath.startsWith(`${targetPath}/`); diff --git a/vercel 2.json b/vercel 2.json new file mode 100644 index 00000000..0f32683a --- /dev/null +++ b/vercel 2.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +}