diff --git a/README.md b/README.md index e99343d1..529c581f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ WhereYouAd Frontend는 Vite + React 기반의 2026 캡스톤 졸업 프로젝트 - [Scripts](#-scripts) - [Environment Variables](#-environment-variables) - [Project Structure](#-project-structure) -- [CI/CD](#-cicd) +- [CI/CD](#cicd) - [Conventions](#-conventions) - [Contributors](#-contributors) @@ -72,6 +72,7 @@ WhereYouAd Frontend는 Vite + React 기반의 2026 캡스톤 졸업 프로젝트 | Markdown | react-markdown (AI 리포트 렌더링) | | Test | Vitest (단위), Playwright (E2E) | | Docs | Storybook 8 + Chromatic (시각 회귀) | +| Monitoring | Sentry (프로덕션 에러 수집) | | Deploy | AWS S3 + CloudFront | | Quality | ESLint v9 (flat config 4파일 분리), Prettier, Husky, lint-staged, Commitlint | @@ -84,24 +85,27 @@ WhereYouAd Frontend는 Vite + React 기반의 2026 캡스톤 졸업 프로젝트 ```bash node -v # v20.x.x + +pnpm -v +# v10.x.x ``` ### Installation ```bash -npm install +pnpm install ``` ### Run Dev Server ```bash -npm run dev +pnpm dev ``` ### Build ```bash -npm run build +pnpm build ```
@@ -110,13 +114,30 @@ npm run build | Command | Description | | -------------------- | --------------------------------------------------- | -| `npm run dev` | Vite 개발 서버를 실행합니다. | -| `npm run build` | TypeScript 빌드 후 Vite 프로덕션 빌드를 생성합니다. | -| `npm run lint` | ESLint로 전체 코드를 검사합니다. | -| `npm run preview` | 빌드 결과를 로컬에서 미리 확인합니다. | -| `npm run prepare` | Husky Git hook을 설치합니다. | -| `npm run test` | Vitest로 단위 테스트를 실행합니다. | -| `npm run test:watch` | Vitest watch 모드로 실행합니다. | +| `pnpm dev` | Vite 개발 서버를 실행합니다. | +| `pnpm build` | TypeScript 빌드 후 Vite 프로덕션 빌드를 생성합니다. | +| `pnpm lint` | ESLint로 전체 코드를 검사합니다. | +| `pnpm preview` | 빌드 결과를 로컬에서 미리 확인합니다. | +| `pnpm prepare` | Husky Git hook을 설치합니다. | +| `pnpm test` | Vitest로 단위 테스트를 실행합니다. | +| `pnpm test:watch` | Vitest watch 모드로 실행합니다. | +| `pnpm test:e2e` | Playwright로 E2E 테스트를 실행합니다. | +| `pnpm storybook` | Storybook 개발 서버를 실행합니다. (port 6006) | + +
+ +## 🔑 Environment Variables + +`.env` 파일을 프로젝트 루트에 생성하고 아래 변수를 설정합니다. + +| 변수 | 설명 | +| ---------------------- | ------------------------------------------------- | +| `VITE_API_BASE_URL` | 백엔드 API 베이스 URL | +| `VITE_NAVER_AES_SECRET`| Naver 광고 OAuth AES 암호화 키 | +| `VITE_NAVER_AES_IV` | Naver 광고 OAuth AES 초기화 벡터 | +| `VITE_SENTRY_DSN` | Sentry 프로젝트 DSN (미설정 시 Sentry 비활성화) | + +> CI/CD 환경에서는 GitHub Secrets로 관리됩니다.
@@ -190,13 +211,15 @@ import { useCoreQuery } from "@/hooks/customQuery";
+ + ## ⚙️ CI/CD -| 워크플로 | 트리거 | 내용 | -| --------------- | ------------------- | ------------------------------------------------- | -| `ci.yaml` | PR → develop / main | ESLint lint → Vitest test → TypeScript build 검증 | -| `main.yaml` | push → main | S3 업로드 → CloudFront 캐시 무효화 → Discord 알림 | -| `chromatic.yml` | push → develop | Storybook 시각 회귀 테스트 (Chromatic) | +| 워크플로 | 트리거 | 내용 | +| --------------- | ------------------- | ------------------------------------------------------------------------------------- | +| `ci.yaml` | PR → develop / main | ESLint → Vitest → TypeScript build → Lighthouse CI (LCP / CLS / TBT 자동 검증) | +| `main.yaml` | push → main | S3 업로드 → CloudFront Functions (CSP 헤더) 자동 배포 → CloudFront 캐시 무효화 → Discord 알림 | +| `chromatic.yml` | push → develop | Storybook 시각 회귀 테스트 (Chromatic) |
@@ -204,25 +227,25 @@ import { useCoreQuery } from "@/hooks/customQuery"; ### Branch -- `feature/#1-description` -- `fix/#1-description` -- `style/#1-description` -- `docs/#1-description` -- `setting/#1-description` -- `refactor/#1-description` +- `feature/#이슈번호` +- `fix/#이슈번호` +- `style/#이슈번호` +- `docs/#이슈번호` +- `setting/#이슈번호` +- `refactor/#이슈번호` ### Commit Conventional Commits 규칙을 따릅니다. commitlint로 자동 검증됩니다. -- `feat: add AI analysis report streaming` -- `fix: prevent duplicate token reissue on parallel 401` -- `refactor: consolidate metric format logic into METRIC_REGISTRY` -- `docs: update README` +- `feat: AI 분석 리포트 스트리밍 추가` +- `fix: 병렬 401 상황에서 토큰 재발급 중복 호출 방지` +- `refactor: 지표 포맷 로직을 METRIC_REGISTRY로 통합` +- `docs: README 업데이트` ### Pull Request -- PR 제목은 `[Feature/#1] 작업 내용` 형식을 권장합니다. +- PR 제목은 `[feat/#이슈번호] 작업 내용` 형식을 권장합니다. - GitHub Issue를 먼저 등록하고 PR 본문에 `closes #이슈번호`를 포함합니다. - 리뷰 기준은 `.cursor/rules/always.mdc`를 따릅니다. diff --git a/package.json b/package.json index 41ffad32..349a9fc1 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.17", "apexcharts": "^5.7.0", - "axios": "^1.13.4", + "axios": "^1.19.0", "crypto-js": "^4.2.0", + "dompurify": "^3.4.13", "framer-motion": "^12.38.0", "react": "^19.2.0", "react-apexcharts": "^2.0.1", @@ -34,7 +35,7 @@ "react-joyride": "^3.2.0", "react-markdown": "^10.1.0", "react-remove-scroll": "^2.7.2", - "react-router-dom": "^7.12.0", + "react-router-dom": "^7.18.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.1", "tailwindcss": "^4.1.18", @@ -78,7 +79,7 @@ "storybook": "8.6.17", "typescript": "~5.9.3", "typescript-eslint": "^8.53.0", - "vite": "^6.4.1", + "vite": "^6.4.3", "vite-plugin-svgr": "^4.5.0", "vitest": "^4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4aff611c..1957313c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 10.68.0(react@19.2.4) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.2.0(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + version: 4.2.0(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) '@tanstack/react-query': specifier: ^5.90.17 version: 5.90.21(react@19.2.4) @@ -27,11 +27,14 @@ importers: specifier: ^5.7.0 version: 5.10.3 axios: - specifier: ^1.13.4 - version: 1.13.5 + specifier: ^1.19.0 + version: 1.19.0 crypto-js: specifier: ^4.2.0 version: 4.2.0 + dompurify: + specifier: ^3.4.13 + version: 3.4.13 framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -60,8 +63,8 @@ importers: specifier: ^2.7.2 version: 2.7.2(@types/react@19.2.14)(react@19.2.4) react-router-dom: - specifier: ^7.12.0 - version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^7.18.2 + version: 7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -101,7 +104,7 @@ importers: version: 8.6.17(@storybook/test@8.6.17(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@8.6.17(prettier@3.8.1))(typescript@5.9.3) '@storybook/react-vite': specifier: 8.6.17 - version: 8.6.17(@storybook/test@8.6.17(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@8.6.17(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + version: 8.6.17(@storybook/test@8.6.17(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@8.6.17(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) '@storybook/test': specifier: 8.6.17 version: 8.6.17(storybook@8.6.17(prettier@3.8.1)) @@ -128,7 +131,7 @@ importers: version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + version: 3.11.0(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -187,14 +190,14 @@ importers: specifier: ^8.53.0 version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + specifier: ^6.4.3 + version: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + version: 4.5.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.10.13)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + version: 4.1.10(@types/node@24.10.13)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) packages: @@ -1357,6 +1360,9 @@ packages: '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1500,6 +1506,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1638,8 +1648,8 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2094,6 +2104,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2467,8 +2480,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2484,8 +2497,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} forwarded@0.2.0: @@ -2645,6 +2658,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -2672,6 +2689,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -3649,6 +3670,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3747,15 +3772,15 @@ packages: '@types/react': optional: true - react-router-dom@7.13.0: - resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} + react-router-dom@7.18.2: + resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.13.0: - resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -4421,8 +4446,8 @@ packages: peerDependencies: vite: '>=2.6.0' - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -5106,12 +5131,12 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.9.3)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': dependencies: glob: 10.5.0 magic-string: 0.27.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 @@ -5464,13 +5489,13 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/builder-vite@8.6.17(storybook@8.6.17(prettier@3.8.1))(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + '@storybook/builder-vite@8.6.17(storybook@8.6.17(prettier@3.8.1))(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': dependencies: '@storybook/csf-plugin': 8.6.17(storybook@8.6.17(prettier@3.8.1)) browser-assert: 1.2.1 storybook: 8.6.17(prettier@3.8.1) ts-dedent: 2.2.0 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) '@storybook/components@8.6.17(storybook@8.6.17(prettier@3.8.1))': dependencies: @@ -5529,11 +5554,11 @@ snapshots: react-dom: 19.2.4(react@19.2.4) storybook: 8.6.17(prettier@3.8.1) - '@storybook/react-vite@8.6.17(@storybook/test@8.6.17(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@8.6.17(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + '@storybook/react-vite@8.6.17(@storybook/test@8.6.17(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@8.6.17(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 8.6.17(storybook@8.6.17(prettier@3.8.1))(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + '@storybook/builder-vite': 8.6.17(storybook@8.6.17(prettier@3.8.1))(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) '@storybook/react': 8.6.17(@storybook/test@8.6.17(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@8.6.17(prettier@3.8.1))(typescript@5.9.3) find-up: 5.0.0 magic-string: 0.30.21 @@ -5543,7 +5568,7 @@ snapshots: resolve: 1.22.11 storybook: 8.6.17(prettier@3.8.1) tsconfig-paths: 4.2.0 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) optionalDependencies: '@storybook/test': 8.6.17(storybook@8.6.17(prettier@3.8.1)) transitivePeerDependencies: @@ -5769,12 +5794,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 - '@tailwindcss/vite@4.2.0(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.0(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.0 '@tailwindcss/oxide': 4.2.0 tailwindcss: 4.2.0 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) '@tanstack/query-core@5.90.20': {} @@ -5884,6 +5909,9 @@ snapshots: '@types/resolve@1.20.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -5988,11 +6016,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@3.11.0(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + '@vitejs/plugin-react-swc@3.11.0(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.15.11 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) transitivePeerDependencies: - '@swc/helpers' @@ -6012,13 +6040,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) '@vitest/pretty-format@2.0.5': dependencies: @@ -6082,6 +6110,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv@6.12.6: @@ -6235,13 +6269,15 @@ snapshots: axe-core@4.11.1: {} - axios@1.13.5: + axios@1.19.0: dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} @@ -6671,6 +6707,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -7216,7 +7256,7 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} for-each@0.3.5: dependencies: @@ -7227,12 +7267,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -7384,6 +7424,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -7433,6 +7477,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -8521,6 +8572,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8660,13 +8713,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-router-dom@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router-dom@7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-router: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-router: 7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router@7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: cookie: 1.1.1 react: 19.2.4 @@ -9459,18 +9512,18 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-svgr@4.5.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)): + vite-plugin-svgr@4.5.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.57.1) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) transitivePeerDependencies: - rollup - supports-color - typescript - vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2): + vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -9485,10 +9538,10 @@ snapshots: lightningcss: 1.31.1 yaml: 2.8.2 - vitest@4.1.10(@types/node@24.10.13)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)): + vitest@4.1.10(@types/node@24.10.13)(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) + '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9505,7 +9558,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + vite: 6.4.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.13 diff --git a/src/assets/logo/social-logo/plain/kakao.svg b/src/assets/logo/social-logo/plain/kakao.svg deleted file mode 100644 index 9eace7bf..00000000 --- a/src/assets/logo/social-logo/plain/kakao.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/components/common/dropdownmenu/DropdownMenu.tsx b/src/components/common/dropdownmenu/DropdownMenu.tsx index 8e196e53..3dfce7b1 100644 --- a/src/components/common/dropdownmenu/DropdownMenu.tsx +++ b/src/components/common/dropdownmenu/DropdownMenu.tsx @@ -24,10 +24,13 @@ const easeIn = [0.4, 0, 1, 1] as const; const MENU_ITEM_HEIGHT = 52; const MENU_VERTICAL_PADDING = 32; +const DEFAULT_MENU_WIDTH = 224; function getClippingBounds(element: HTMLElement) { let bottom = window.innerHeight; let top = 0; + let left = 0; + let right = window.innerWidth; let parent = element.parentElement; while (parent) { @@ -45,12 +48,14 @@ function getClippingBounds(element: HTMLElement) { const rect = parent.getBoundingClientRect(); bottom = Math.min(bottom, rect.bottom); top = Math.max(top, rect.top); + left = Math.max(left, rect.left); + right = Math.min(right, rect.right); } parent = parent.parentElement; } - return { top, bottom }; + return { top, bottom, left, right }; } function resolveAutoPlacement( @@ -65,11 +70,37 @@ function resolveAutoPlacement( const spaceBelow = containerBottom - triggerRect.bottom; const spaceAbove = triggerRect.top - containerTop; - return spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow + const visibleHeight = containerBottom - containerTop; + + const inLowerArea = + visibleHeight > 0 && + triggerRect.bottom > containerTop + visibleHeight * 0.65; + + if (inLowerArea && spaceAbove >= estimatedMenuHeight) { + return "top"; + } + + return spaceBelow < estimatedMenuHeight && spaceAbove >= estimatedMenuHeight ? "top" : "bottom"; } +function resolveHorizontalAlign( + element: HTMLElement, + menuWidth = DEFAULT_MENU_WIDTH, +): "left" | "right" { + const triggerRect = element.getBoundingClientRect(); + const { left: containerLeft, right: containerRight } = + getClippingBounds(element); + const spaceLeft = triggerRect.right - containerLeft; + const spaceRight = containerRight - triggerRect.left; + + if (spaceLeft < menuWidth && spaceRight > spaceLeft) { + return "left"; + } + return "right"; +} + export function DropdownMenu({ trigger, items, @@ -98,6 +129,9 @@ export function DropdownMenu({ const [resolvedPlacement, setResolvedPlacement] = useState<"bottom" | "top">( "bottom", ); + const [horizontalAlign, setHorizontalAlign] = useState<"left" | "right">( + "right", + ); const ref = useRef(null); const menuId = useId(); @@ -118,30 +152,34 @@ export function DropdownMenu({ useLayoutEffect(() => { if (!open || inFlow) { setResolvedPlacement("bottom"); + setHorizontalAlign("right"); return; } + const el = ref.current; + if (!el) return; + if (placement === "bottom") { setResolvedPlacement("bottom"); - return; - } - - if (placement === "top") { + } else if (placement === "top") { setResolvedPlacement("top"); - return; + } else { + setResolvedPlacement(resolveAutoPlacement(el, items.length)); } - const el = ref.current; - if (!el) return; - - setResolvedPlacement(resolveAutoPlacement(el, items.length)); - }, [open, placement, items.length, inFlow]); + if (!fullWidth) { + setHorizontalAlign(resolveHorizontalAlign(el)); + } else { + setHorizontalAlign("right"); + } + }, [open, placement, items.length, inFlow, fullWidth]); useEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]); const isTopPlacement = !inFlow && resolvedPlacement === "top"; + const isLeftAlign = !inFlow && !fullWidth && horizontalAlign === "left"; return (
({ aria-controls={listboxId} aria-haspopup="listbox" aria-autocomplete="list" - className="h-13 w-full rounded-2xl border border-info-blue px-4 pl-5 font-body1 outline-none transition-colors placeholder:text-text-placeholder focus:border-info-blue" + containerClassName="h-13 w-full rounded-2xl border border-info-blue font-body1 outline-none transition-colors placeholder:text-text-placeholder focus:border-info-blue" + inputClassName="truncate" + rightElement={ + + } /> -
{isOpen && ( diff --git a/src/components/dashboard/ai-report/print/downloadAiSummaryPdf.ts b/src/components/dashboard/ai-report/print/downloadAiSummaryPdf.ts index 25277c54..bef5867c 100644 --- a/src/components/dashboard/ai-report/print/downloadAiSummaryPdf.ts +++ b/src/components/dashboard/ai-report/print/downloadAiSummaryPdf.ts @@ -1,5 +1,6 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import DOMPurify from "dompurify"; import AiSummaryPrintReport from "./AiSummaryPrintReport"; import { buildPrintThemeStyleBlock } from "./printAssets"; @@ -47,7 +48,7 @@ export function downloadAiSummaryPdf(document: TAiReportPrintDocument) { -${reportMarkup} +${DOMPurify.sanitize(reportMarkup)} `); printDoc.close(); diff --git a/src/components/dashboard/platform/AllPlatformTrafficChart.tsx b/src/components/dashboard/platform/AllPlatformTrafficChart.tsx index 82a3dd7e..65c6798b 100644 --- a/src/components/dashboard/platform/AllPlatformTrafficChart.tsx +++ b/src/components/dashboard/platform/AllPlatformTrafficChart.tsx @@ -19,6 +19,10 @@ import { useClickStream } from "@/hooks/dashboard/useClickStream"; import Button from "@/components/common/button/Button"; import { Skeleton } from "@/components/common/skeleton/Skeleton"; +import { + PLATFORM_ALL_TRAFFIC_CHART_ID, + PLATFORM_ALL_TRAFFIC_CONTAINER_ID, +} from "@/components/dashboard/platform/platformTrafficChartDownload.config"; const STREAM_MODE = "dummy" as const; @@ -93,6 +97,7 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart() { const chartOptions: ApexOptions = { chart: { + id: PLATFORM_ALL_TRAFFIC_CHART_ID, type: "area", toolbar: { show: false }, zoom: { enabled: false }, @@ -211,7 +216,7 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart() { )} -
+
({ label: PLATFORM_MAP[provider], color: PLATFORM_CHART_COLORS[provider], })); +const PLATFORM_LOGOS: Record = { + GOOGLE: , + NAVER: , + META: , +}; + +function PlatformDetailErrorCard({ provider }: { provider: TProviderType }) { + return ( + +
+
{PLATFORM_LOGOS[provider]}
+

+ {PLATFORM_MAP[provider]} +

+
+
+ 데이터를 불러오지 못했습니다. +
+
+ ); +} + export default function AllPlatformView() { const { data: roasRankings, @@ -47,11 +77,15 @@ export default function AllPlatformView() { } = usePlatformAdCount(); const { - data: platformPerformance, + data: performanceData, isLoading: isPerformanceLoading, isError: isPerformanceError, } = usePlatformPerformance(); + const platformPerformance = performanceData?.platforms; + const failedProviders = performanceData?.failedProviders ?? []; + const hasPartialFailure = failedProviders.length > 0; + return (
@@ -154,7 +188,14 @@ export default function AllPlatformView() { 표시할 성과 데이터가 없습니다.
) : ( - +
+ {hasPartialFailure ? ( +

+ 일부 플랫폼 성과를 불러오지 못했습니다. +

+ ) : null} + +
)} @@ -165,6 +206,7 @@ export default function AllPlatformView() { title="플랫폼별 실시간 클릭수 비교" className="h-120 flex flex-col" description={} + RightElement={} >
@@ -176,7 +218,7 @@ export default function AllPlatformView() { {/* 개별 플랫폼 상세 */}
{isPerformanceLoading ? ( @@ -187,14 +229,21 @@ export default function AllPlatformView() {
데이터를 불러오지 못했습니다.
- ) : platformPerformance.length === 0 ? ( -
- 표시할 플랫폼 데이터가 없습니다. -
) : ( - platformPerformance.map((platform) => ( - - )) + PROVIDER_TYPES.map((provider) => { + const data = platformPerformance.find( + (platform) => platform.provider === provider, + ); + if (data) { + return ; + } + if (failedProviders.includes(provider)) { + return ( + + ); + } + return null; + }) )}
diff --git a/src/components/dashboard/platform/PlatformRoasTable.tsx b/src/components/dashboard/platform/PlatformRoasTable.tsx index 78a64f2a..05e8820c 100644 --- a/src/components/dashboard/platform/PlatformRoasTable.tsx +++ b/src/components/dashboard/platform/PlatformRoasTable.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useState } from "react"; import type { IPlatformRankingItem, @@ -11,6 +11,8 @@ import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; import { TrendBadge } from "@/components/common/card/StatCard"; +import ChevronDownIcon from "@/assets/icon/chevron/chevron-down.svg?react"; + function toProviderType(provider: string): TProviderType | null { const key = provider.toUpperCase(); if (key in PLATFORM_MAP) return key as TProviderType; @@ -50,20 +52,166 @@ const Delta = memo(function Delta({ value }: { value: number }) { }); export const PLATFORM_ROAS_TABLE_COL = - "grid-cols-[36px_minmax(0,1.45fr)_minmax(5.5rem,7.25rem)_minmax(0,1.25fr)] @2xl:grid-cols-[36px_minmax(0,1.15fr)_minmax(5.5rem,7.25rem)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.28fr)]"; + "mobile:grid-cols-[28px_minmax(0,1fr)_minmax(4.5rem,5.5rem)_20px] grid-cols-[36px_minmax(0,1.45fr)_minmax(5.5rem,7.25rem)_minmax(0,1.28fr)] @2xl:grid-cols-[36px_minmax(0,1.15fr)_minmax(5.5rem,7.25rem)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.28fr)]"; interface IPlatformRoasTableProps { rankings: IPlatformRankingItem[]; } +const PlatformRoasRow = memo(function PlatformRoasRow({ + item, +}: { + item: IPlatformRankingItem; +}) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + <> +
+ {/* 순위 */} +
+ + {item.rank} + +
+ + {/* 플랫폼 이름 */} +
+
+ {getPlatformLogo(item.provider)} +
+ + {getDisplayName(item.provider)} + +
+ + {/* ROAS */} +
+ + {M.roas.format(item.roas)} + +
+ + {/* 클릭수 */} +
+ {item.clicks !== undefined ? ( + <> + + {M.clicks.format(item.clicks)} + + {item.clickDelta !== undefined && ( +
+ +
+ )} + + ) : ( + - + )} +
+ + {/* CVR */} +
+ {item.conversionRate !== undefined ? ( + <> + + {M.conversion.format(item.conversionRate)} + + {item.conversionDelta !== undefined && ( +
+ +
+ )} + + ) : ( + - + )} +
+ + {/* 매출/광고비 - 모바일에서 숨김 */} +
+ + {M.revenue.format(item.revenue)} + +
+ {M.adSpend.label} + + {M.adSpend.format(item.adSpend)} + +
+
+ + {/* 펼치기/접기 버튼 - 모바일 전용 */} + +
+ + {/* 펼쳐진 상세 정보 - 모바일 전용 */} + {isExpanded && ( +
+
+
+ + {M.revenue.label} + + + {M.revenue.format(item.revenue)} + +
+
+ + {M.adSpend.label} + + + {M.adSpend.format(item.adSpend)} + +
+ {item.clicks !== undefined && ( +
+ + {M.clicks.label} + + + {M.clicks.format(item.clicks)} + +
+ )} + {item.conversionRate !== undefined && ( +
+ + {M.conversion.label} + + + {M.conversion.format(item.conversionRate)} + +
+ )} +
+
+ )} + + ); +}); + const PlatformRoasTable = memo(function PlatformRoasTable({ rankings, }: IPlatformRoasTableProps) { return (
+ {/* 헤더 */}
순위 @@ -78,9 +226,12 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ {M.conversion.label} - + {/* 매출/광고비 헤더 - 모바일에서 숨김 */} + {M.revenue.label} / {M.adSpend.label} + {/* 펼치기 헤더 placeholder - 모바일 전용 */} +
{rankings.length === 0 && ( @@ -90,83 +241,7 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ )}
{rankings.map((item) => ( -
- {/* 순위 */} -
- - {item.rank} - -
- - {/* 플랫폼 이름 */} -
-
- {getPlatformLogo(item.provider)} -
- - {getDisplayName(item.provider)} - -
- - {/* ROAS */} -
- - {M.roas.format(item.roas)} - -
- - {/* 클릭수 */} -
- {item.clicks !== undefined ? ( - <> - - {M.clicks.format(item.clicks)} - - {item.clickDelta !== undefined && ( -
- -
- )} - - ) : ( - - - )} -
- - {/* CVR */} -
- {item.conversionRate !== undefined ? ( - <> - - {M.conversion.format(item.conversionRate)} - - {item.conversionDelta !== undefined && ( -
- -
- )} - - ) : ( - - - )} -
- - {/* 매출/광고비 */} -
- - {M.revenue.format(item.revenue)} - -
- {M.adSpend.label} - - {M.adSpend.format(item.adSpend)} - -
-
-
+ ))}
diff --git a/src/components/dashboard/platform/PlatformTrafficChart.tsx b/src/components/dashboard/platform/PlatformTrafficChart.tsx index 87999ea4..244b920c 100644 --- a/src/components/dashboard/platform/PlatformTrafficChart.tsx +++ b/src/components/dashboard/platform/PlatformTrafficChart.tsx @@ -31,6 +31,10 @@ import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp import Button from "@/components/common/button/Button"; import { Skeleton } from "@/components/common/skeleton/Skeleton"; import { useAnomalyMarkerPos } from "@/components/dashboard/charts/useAnomalyMarkerPos"; +import { + PLATFORM_TRAFFIC_CHART_ID, + PLATFORM_TRAFFIC_CONTAINER_ID, +} from "@/components/dashboard/platform/platformTrafficChartDownload.config"; interface IPlatformTrafficChartProps { data: IClickStreamItem | null; @@ -175,6 +179,7 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({ const chartOptions: ApexOptions = { chart: { + id: PLATFORM_TRAFFIC_CHART_ID, type: "area", toolbar: { show: false }, zoom: { enabled: false }, @@ -428,6 +433,7 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({ ) : (
+