diff --git a/.storybook/main.ts b/.storybook/main.ts index 7ef96386..8eaad833 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,8 +1,19 @@ import type { StorybookConfig } from "@storybook/react-vite"; +import type { InlineConfig } from "vite"; const config: StorybookConfig = { stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], addons: ["@storybook/addon-essentials"], framework: "@storybook/react-vite", + async viteFinal(viteConfig: InlineConfig) { + viteConfig.define = { + ...(viteConfig.define ?? {}), + "import.meta.env.VITE_API_BASE_URL": JSON.stringify( + "http://localhost:8080", + ), + }; + return viteConfig; + }, }; + export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 86263cbb..34b14cd8 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,13 +2,23 @@ import "../src/index.css"; import { MemoryRouter } from "react-router-dom"; import type { Preview } from "@storybook/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}); const preview: Preview = { decorators: [ (Story) => ( - - - + + + + + ), ], parameters: { diff --git a/package-lock.json b/package-lock.json index b1082918..2725a8b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "react": "^19.2.0", "react-apexcharts": "^2.0.1", "react-dom": "^19.2.0", + "react-error-boundary": "^6.1.2", "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-remove-scroll": "^2.7.2", @@ -32,6 +33,7 @@ "@commitlint/cli": "^20.3.1", "@commitlint/config-conventional": "^20.3.1", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.60.0", "@storybook/addon-essentials": "8.6.17", "@storybook/react": "8.6.17", "@storybook/react-vite": "8.6.17", @@ -43,6 +45,7 @@ "@typescript-eslint/eslint-plugin": "^8.53.0", "@typescript-eslint/parser": "^8.53.0", "@vitejs/plugin-react-swc": "^3.11.0", + "dotenv": "^17.4.2", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -1508,6 +1511,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -5044,6 +5063,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9215,6 +9247,53 @@ "node": ">=0.10" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -9476,6 +9555,15 @@ "react": "^19.2.4" } }, + "node_modules/react-error-boundary": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz", + "integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-hook-form": { "version": "7.71.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", diff --git a/package.json b/package.json index 7860646f..be5aea9e 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,12 @@ "@tanstack/react-query": "^5.90.17", "apexcharts": "^5.7.0", "axios": "^1.13.4", + "crypto-js": "^4.2.0", "framer-motion": "^12.38.0", "react": "^19.2.0", "react-apexcharts": "^2.0.1", "react-dom": "^19.2.0", + "react-error-boundary": "^6.1.2", "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-remove-scroll": "^2.7.2", @@ -45,6 +47,7 @@ "@storybook/react-vite": "8.6.17", "@storybook/test": "8.6.17", "@tailwindcss/forms": "^0.5.11", + "@types/crypto-js": "^4.2.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85c8cda9..87a9a970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: axios: specifier: ^1.13.4 version: 1.13.5 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -38,6 +41,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) + react-error-boundary: + specifier: ^6.1.2 + version: 6.1.2(react@19.2.4) react-hook-form: specifier: ^7.71.1 version: 7.71.1(react@19.2.4) @@ -93,6 +99,9 @@ importers: '@tailwindcss/forms': specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.2.0) + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/node': specifier: ^24.10.1 version: 24.10.13 @@ -1160,6 +1169,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1597,6 +1609,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2925,6 +2940,11 @@ packages: peerDependencies: react: ^19.2.4 + react-error-boundary@6.1.2: + resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-hook-form@7.71.1: resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==} engines: {node: '>=18.0.0'} @@ -4518,6 +4538,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/crypto-js@4.2.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5017,6 +5039,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -6553,6 +6577,10 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-error-boundary@6.1.2(react@19.2.4): + dependencies: + react: 19.2.4 + react-hook-form@7.71.1(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/api/integration/naver.ts b/src/api/integration/naver.ts index fac088bf..4a6ee817 100644 --- a/src/api/integration/naver.ts +++ b/src/api/integration/naver.ts @@ -4,14 +4,37 @@ import type { INaverCredentialsRequest, } from "@/types/integration/naver"; +import { encryptAesCbcBase64 } from "@/utils/integration/encryptAesCbcBase64"; + import { axiosInstance } from "@/lib/axiosInstance"; +/** 폼 입력(평문) → API body(키만 암호화) */ +function toNaverEncryptedBody( + body: INaverCredentialsRequest, +): INaverCredentialsRequest { + return { + customerId: body.customerId.trim(), + apiKey: encryptAesCbcBase64(body.apiKey.trim()), + secretKey: encryptAesCbcBase64(body.secretKey.trim()), + }; +} + export async function connectNaverAccount( orgId: number, body: INaverCredentialsRequest, ): Promise { await axiosInstance.post>( `/api/platform/${orgId}/accounts/naver`, - body, + toNaverEncryptedBody(body), + ); +} + +export async function updateNaverAccount( + orgId: number, + body: INaverCredentialsRequest, +): Promise { + await axiosInstance.patch( + `/api/platform/${orgId}/accounts/naver`, + toNaverEncryptedBody(body), ); } diff --git a/src/api/integration/platformAccounts.ts b/src/api/integration/platformAccounts.ts index f591f373..fdf66d7e 100644 --- a/src/api/integration/platformAccounts.ts +++ b/src/api/integration/platformAccounts.ts @@ -11,3 +11,10 @@ export const getPlatformAccounts = async ( >(`/api/platform/${orgId}/accounts`); return data.data; }; + +export async function disconnectPlatformAccount( + orgId: number, + accountId: number, +): Promise { + await axiosInstance.delete(`/api/platform/${orgId}/accounts/${accountId}`); +} diff --git a/src/api/timeline/timeline.ts b/src/api/timeline/timeline.ts new file mode 100644 index 00000000..78a1bac2 --- /dev/null +++ b/src/api/timeline/timeline.ts @@ -0,0 +1,86 @@ +import type { ICommonResponse } from "@/types/common/common"; +import { + type ITimelineDetail, + type ITimelineListItem, + type ITimelineMutationResponse, + type ITimelineUpsertRequest, + type TTimelineEmptyResponse, +} from "@/types/timeline/api"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +//타임라인 상세 조회 API +export const getTimelineDetail = async ( + orgId: number, + timelineId: number, +): Promise => { + const { data } = await axiosInstance.get>( + `/api/org/${orgId}/timeline/${timelineId}`, + ); + return data.data; +}; + +//타임라인 목록 조회 API +export const getTimelineList = async ( + orgId: number, +): Promise => { + const { data } = await axiosInstance.get< + ICommonResponse + >(`/api/org/${orgId}/timeline`); + return data.data; +}; + +//타임라인 생성 API +export const createTimeline = async ( + orgId: number, + body: ITimelineUpsertRequest, +): Promise => { + const { data } = await axiosInstance.post< + ICommonResponse + >(`/api/org/${orgId}/timeline`, body, { + validateStatus: (status) => + status === 201 || (status >= 200 && status < 300), + }); + return data.data; +}; + +//타임라인 수정 API +export const updateTimeline = async ( + orgId: number, + timelineId: number, + body: ITimelineUpsertRequest, +): Promise => { + const { data } = await axiosInstance.put< + ICommonResponse + >(`/api/org/${orgId}/timeline/${timelineId}`, body); + return data.data; +}; + +//타임라인 삭제 API +export const deleteTimeline = async ( + orgId: number, + timelineId: number, +): Promise => { + const { data } = await axiosInstance.delete< + ICommonResponse + >(`/api/org/${orgId}/timeline/${timelineId}`); + return data.data; +}; + +//타임라인 AI 요약 요청 API +export const requestTimelineSummary = async ( + orgId: number, + timelineId: number, +): Promise => { + const { data } = await axiosInstance.post< + ICommonResponse + >( + `/api/org/${orgId}/timeline/${timelineId}/summary`, + {}, + { + validateStatus: (status) => + status === 202 || (status >= 200 && status < 300), + }, + ); + return data.data; +}; diff --git "a/src/assets/icon/ai/ai-\354\232\224\354\225\275\353\262\204\355\212\274.svg" "b/src/assets/icon/ai/ai-\354\232\224\354\225\275\353\262\204\355\212\274.svg" deleted file mode 100644 index 21bf98de..00000000 --- "a/src/assets/icon/ai/ai-\354\232\224\354\225\275\353\262\204\355\212\274.svg" +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/assets/icon/ai/sparkle-circle.svg b/src/assets/icon/ai/sparkle-circle.svg deleted file mode 100644 index cf946f25..00000000 --- a/src/assets/icon/ai/sparkle-circle.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/assets/icon/common/lightbulb.svg b/src/assets/icon/common/lightbulb.svg deleted file mode 100644 index 444731f0..00000000 --- a/src/assets/icon/common/lightbulb.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icon/common/plus.svg b/src/assets/icon/common/plus.svg index a6449973..963345af 100644 --- a/src/assets/icon/common/plus.svg +++ b/src/assets/icon/common/plus.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/logo/social-logo/plain/google_ads.png b/src/assets/logo/social-logo/plain/google_ads.png deleted file mode 100644 index 587eaeef..00000000 Binary files a/src/assets/logo/social-logo/plain/google_ads.png and /dev/null differ diff --git a/src/assets/logo/social-logo/plain/meta.svg b/src/assets/logo/social-logo/plain/meta.svg deleted file mode 100644 index 22806c9f..00000000 --- a/src/assets/logo/social-logo/plain/meta.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/logo/social-logo/wordmark/kakao-wordmark.svg b/src/assets/logo/social-logo/wordmark/kakao-wordmark.svg deleted file mode 100644 index fab4e477..00000000 --- a/src/assets/logo/social-logo/wordmark/kakao-wordmark.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/assets/logo/social-logo/wordmark/naver-wordmark.png b/src/assets/logo/social-logo/wordmark/naver-wordmark.png deleted file mode 100644 index aa323f73..00000000 Binary files a/src/assets/logo/social-logo/wordmark/naver-wordmark.png and /dev/null differ diff --git a/src/assets/mockup/footer_bg.jpg b/src/assets/mockup/footer_bg.jpg new file mode 100644 index 00000000..efbc9cc9 Binary files /dev/null and b/src/assets/mockup/footer_bg.jpg differ diff --git a/src/assets/mockup/iOS app dock.png b/src/assets/mockup/iOS app dock.png deleted file mode 100644 index 139d5d90..00000000 Binary files a/src/assets/mockup/iOS app dock.png and /dev/null differ diff --git a/src/assets/mockup/iPad Air mockup.png b/src/assets/mockup/iPad Air mockup.png deleted file mode 100644 index ef5f2af0..00000000 Binary files a/src/assets/mockup/iPad Air mockup.png and /dev/null differ diff --git a/src/assets/mockup/optimized/timeline_dashboard.jpg b/src/assets/mockup/optimized/timeline_dashboard.jpg deleted file mode 100644 index bcdb591f..00000000 Binary files a/src/assets/mockup/optimized/timeline_dashboard.jpg and /dev/null differ diff --git a/src/components/common/ComingSoonPlaceholder.tsx b/src/components/common/ComingSoonPlaceholder.tsx index 8011f96f..46957a36 100644 --- a/src/components/common/ComingSoonPlaceholder.tsx +++ b/src/components/common/ComingSoonPlaceholder.tsx @@ -52,7 +52,7 @@ const ComingSoonPlaceholder = memo(function ComingSoonPlaceholder({ )} > spaceBelow + ? "top" + : "bottom"; +} + export function DropdownMenu({ trigger, items, className, menuClassName, fullWidth = false, + placement = "bottom", "aria-label": ariaLabel, + onOpenChange, }: { trigger: React.ReactNode | ((open: boolean) => React.ReactNode); items: TMenuItem[]; @@ -28,10 +86,16 @@ export function DropdownMenu({ menuClassName?: string; /** 트리거와 동일 너비로 패널을 펼침 (폼 필드용) */ fullWidth?: boolean; + /** bottom: 아래 / top: 위 / auto: 공간에 따라 자동 */ + placement?: TDropdownPlacement; "aria-label"?: string; + onOpenChange?: (open: boolean) => void; }) { const reduceMotion = useReducedMotion(); const [open, setOpen] = useState(false); + const [resolvedPlacement, setResolvedPlacement] = useState<"bottom" | "top">( + "bottom", + ); const ref = useRef(null); const menuId = useId(); @@ -49,6 +113,31 @@ export function DropdownMenu({ }; }, []); + useLayoutEffect(() => { + if (!open) return; + + if (placement === "bottom") { + setResolvedPlacement("bottom"); + return; + } + + if (placement === "top") { + setResolvedPlacement("top"); + return; + } + + const el = ref.current; + if (!el) return; + + setResolvedPlacement(resolveAutoPlacement(el, items.length)); + }, [open, placement, items.length]); + + useEffect(() => { + onOpenChange?.(open); + }, [open, onOpenChange]); + + const isTopPlacement = resolvedPlacement === "top"; + return ( diff --git a/src/components/integration/PlatformDisconnectModal.tsx b/src/components/integration/PlatformDisconnectModal.tsx new file mode 100644 index 00000000..990f28c6 --- /dev/null +++ b/src/components/integration/PlatformDisconnectModal.tsx @@ -0,0 +1,94 @@ +import { PLATFORM_MAP } from "@/types/dashboard/provider"; +import type { TIntegrationProvider } from "@/types/integration/platformConnection"; + +import Button from "@/components/common/button/Button"; +import Modal from "@/components/common/modal/Modal"; + +import WarnIcon from "@/assets/icon/common/warn-circle.svg?react"; + +interface IPlatformDisconnectModalProps { + isOpen: boolean; + onClose: () => void; + provider: TIntegrationProvider; + externalAccountId?: string; + onConfirm: () => void; + isLoading?: boolean; +} + +export default function PlatformDisconnectModal({ + isOpen, + onClose, + provider, + externalAccountId, + onConfirm, + isLoading = false, +}: IPlatformDisconnectModalProps) { + const platformLabel = PLATFORM_MAP[provider] ?? provider; + + const handleConfirm = () => { + if (isLoading) return; + onConfirm(); + }; + + const handleClose = () => { + if (isLoading) return; + onClose(); + }; + + return ( + +
+
+
+ +

+ {platformLabel} 광고 계정 연동을 해제하시겠습니까? +

+ +

+ {externalAccountId ? ( + <> + 연동 계정 ·{" "} + {externalAccountId} +
+ + ) : null} + 연동 해제 시 관련 데이터가 삭제되며 복구할 수 없습니다. +

+ +
+ + +
+
+
+ ); +} diff --git a/src/components/landing/GuideOverviewChart.tsx b/src/components/landing/GuideOverviewChart.tsx index 046bd55b..7a624ac8 100644 --- a/src/components/landing/GuideOverviewChart.tsx +++ b/src/components/landing/GuideOverviewChart.tsx @@ -1,33 +1,145 @@ -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useState } from "react"; +import type { ApexOptions } from "apexcharts"; import { LANDING_OVERVIEW_CHART_OPTIONS, LANDING_OVERVIEW_CHART_SERIES, + NORMALIZED_CLICKS, } from "@/constants/landing/overviewChart"; import Card from "@/components/common/card/Card"; import ChartLegend from "@/components/common/chart/ChartLegend"; +import { DropdownMenu } from "@/components/common/dropdownmenu/DropdownMenu"; + +import ChevronDown from "@/assets/icon/chevron/chevron-down.svg?react"; const ReactApexChart = lazy(() => import("react-apexcharts")); +type TPlatformFilter = "통합" | "NAVER" | "META" | "GOOGLE"; + +const PLATFORM_COLORS: Record< + TPlatformFilter, + { primary: string; secondary: string } +> = { + 통합: { + primary: "var(--color-primary-400)", + secondary: "var(--color-primary-500)", + }, + NAVER: { + primary: "var(--color-oauth-naver)", + secondary: "var(--color-oauth-naver)", + }, + META: { + primary: "var(--color-platform-meta)", + secondary: "var(--color-platform-meta)", + }, + GOOGLE: { + primary: "var(--color-platform-google)", + secondary: "var(--color-platform-google)", + }, +}; + +const PLATFORM_OPTIONS: TPlatformFilter[] = ["통합", "NAVER", "META", "GOOGLE"]; + +const SPLIT_INDEX = 12; +const SPLIT_Y = 48500; + +const SINGLE_SERIES_DATA = NORMALIZED_CLICKS.map((y, i) => ({ x: i, y })); + +const PLATFORM_SERIES_MAP: Record< + Exclude, + { name: string; data: { x: number; y: number }[] }[] +> = { + NAVER: [{ name: "NAVER 클릭수", data: SINGLE_SERIES_DATA }], + META: [{ name: "META 클릭수", data: SINGLE_SERIES_DATA }], + GOOGLE: [{ name: "GOOGLE 클릭수", data: SINGLE_SERIES_DATA }], +}; + +function getChartOptions( + primary: string, + secondary: string, + isUnified: boolean, +): ApexOptions { + return { + ...LANDING_OVERVIEW_CHART_OPTIONS, + colors: isUnified ? [primary, secondary] : [primary], + stroke: isUnified + ? { + curve: "smooth", + width: [3.5, 3.2], + dashArray: [0, 6], + lineCap: "round", + } + : { + curve: "smooth", + width: [3.5], + dashArray: [0], + lineCap: "round", + }, + annotations: isUnified + ? { + xaxis: [ + { x: SPLIT_INDEX, borderColor: secondary, strokeDashArray: 0 }, + ], + points: [ + { + x: SPLIT_INDEX, + y: SPLIT_Y, + marker: { size: 5, fillColor: primary, strokeColor: primary }, + }, + ], + } + : {}, + }; +} + export default function GuideOverviewChart() { + const [selected, setSelected] = useState("통합"); + + const { primary, secondary } = PLATFORM_COLORS[selected]; + const isUnified = selected === "통합"; + const chartOptions = getChartOptions(primary, secondary, isUnified); + return ( -
+
+ } + RightElement={ + ( +
+ {selected} + +
+ )} + menuClassName="w-32" + items={PLATFORM_OPTIONS.map((option) => ({ + label: option, + active: option === selected, + onClick: () => setSelected(option), + }))} /> } >

- {"실시간 트래픽 변화 차트(목업). 클릭수와 예측 클릭수를 시간 흐름에 따라 비교합니다. " + - "오후 12시 기준 클릭수 48,500, 전시간 대비 +1.9%로 표시됩니다."} + {isUnified + ? "실시간 트래픽 변화 차트(목업). 클릭수와 예측 클릭수를 시간 흐름에 따라 비교합니다. 오후 12시 기준 클릭수 48,500, 전시간 대비 +1.9%로 표시됩니다." + : `실시간 트래픽 변화 차트(목업). ${selected} 플랫폼의 클릭수 변화를 보여줍니다.`}

-
-

광고 클릭수 추이

-

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

-

- 전시간 대비 +1.9% -

-
+ {isUnified && ( +
+

광고 클릭수 추이

+

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

+

+ 전시간 대비 +1.9% +

+
+ )} + ] + } height="100%" />
diff --git a/src/components/landing/GuidePlatform.tsx b/src/components/landing/GuidePlatform.tsx index 177a4907..cab655ae 100644 --- a/src/components/landing/GuidePlatform.tsx +++ b/src/components/landing/GuidePlatform.tsx @@ -1,115 +1,59 @@ 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 NaverWordmarkPng from "@/assets/logo/social-logo/wordmark/naver-wordmark.png"; +import Badge from "@/components/common/badge/Badge"; -type TPlatform = { - id: string; - label: string; - content: ReactNode; - offsetClass?: string; -}; +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 PLATFORMS: TPlatform[] = [ +const PLATFORMS: { + id: string; + name: string; + logo: ReactNode; + connected: boolean; +}[] = [ { id: "naver", - label: "Naver", - content: ( - Naver - ), - }, - { - id: "google", - label: "Google", - content: , + name: "NAVER", + logo: , + connected: true, }, { id: "meta", - label: "Meta", - content: , - offsetClass: "-ml-2", + name: "Meta", + logo: , + connected: false, }, { - id: "googleads", - label: "Google Ads", - content: ( - Google Ads - ), - offsetClass: "-ml-2", + id: "google", + name: "Google", + logo: , + connected: false, }, ]; 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 ( - - ); - })} -
-
- )} -
+ + {platform.connected ? "연동됨" : "미연동"} + + {!platform.connected && ( + + 연동하기 + + )} +
+ ))}
); } diff --git a/src/components/landing/GuideTimeline.tsx b/src/components/landing/GuideTimeline.tsx index e494ef63..d1dea8f1 100644 --- a/src/components/landing/GuideTimeline.tsx +++ b/src/components/landing/GuideTimeline.tsx @@ -16,7 +16,7 @@ export default function GuideTimeline() { const totalWidth = LANDING_TIMELINE_COLUMNS.length * colWidth; return ( -
+