From f9a745d5c01bc6654f0356b1655630469eca14d2 Mon Sep 17 00:00:00 2001 From: YermIm Date: Sun, 24 May 2026 06:42:52 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=AA=A9=EB=A1=9D=20mock=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icon/sidebar/connect.svg | 3 +++ .../integration/platformIntegrations.mock.ts | 20 +++++++++++++++++++ src/types/integration/platformConnection.ts | 19 ++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 src/assets/icon/sidebar/connect.svg create mode 100644 src/pages/integration/platformIntegrations.mock.ts create mode 100644 src/types/integration/platformConnection.ts diff --git a/src/assets/icon/sidebar/connect.svg b/src/assets/icon/sidebar/connect.svg new file mode 100644 index 0000000..776084e --- /dev/null +++ b/src/assets/icon/sidebar/connect.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/pages/integration/platformIntegrations.mock.ts b/src/pages/integration/platformIntegrations.mock.ts new file mode 100644 index 0000000..3558668 --- /dev/null +++ b/src/pages/integration/platformIntegrations.mock.ts @@ -0,0 +1,20 @@ +import type { IPlatformConnectionItem } from "@/types/integration/platformConnection"; + +export const platformConnectionsMock: IPlatformConnectionItem[] = [ + { + provider: "META", + status: "connected", + accountLabel: "Meta Ads · 메인 계정", + lastSyncedAt: "2026-05-18T14:32:00", + }, + { + provider: "GOOGLE", + status: "disconnected", + }, + { + provider: "NAVER", + status: "error", + accountLabel: "네이버 검색광고", + errorMessage: "토큰이 만료되었습니다. 다시 연동해 주세요.", + }, +]; diff --git a/src/types/integration/platformConnection.ts b/src/types/integration/platformConnection.ts new file mode 100644 index 0000000..9659aec --- /dev/null +++ b/src/types/integration/platformConnection.ts @@ -0,0 +1,19 @@ +import type { TProvider } from "@/types/ads/campaign"; + +export type TIntegrationProvider = TProvider; + +export type TPlatformConnectionStatus = + | "disconnected" + | "connected" + | "error" + | "syncing"; + +export interface IPlatformConnectionItem { + provider: TIntegrationProvider; + status: TPlatformConnectionStatus; + /** 연동된 광고 계정 표시명 */ + accountLabel?: string; + /** ISO 문자열 또는 화면용 문자열 */ + lastSyncedAt?: string; + errorMessage?: string; +} From 48f146461e268975bfbc540f1d3c1e6b3122ea80 Mon Sep 17 00:00:00 2001 From: YermIm Date: Sun, 24 May 2026 20:59:44 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20UI=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/PlatformIntegrationCard.tsx | 164 ++++++++++++++++++ src/constants/sidebarNav.ts | 7 + src/layout/main/MainLayout.tsx | 16 +- .../integration/PlatformIntegrationsPage.tsx | 27 +++ .../integration/platformIntegrations.mock.ts | 3 +- src/routes/MainRoutes.tsx | 7 + src/types/integration/platformConnection.ts | 2 - 7 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 src/components/integration/PlatformIntegrationCard.tsx create mode 100644 src/pages/integration/PlatformIntegrationsPage.tsx diff --git a/src/components/integration/PlatformIntegrationCard.tsx b/src/components/integration/PlatformIntegrationCard.tsx new file mode 100644 index 0000000..bbe5009 --- /dev/null +++ b/src/components/integration/PlatformIntegrationCard.tsx @@ -0,0 +1,164 @@ +import { memo, type ReactNode } from "react"; + +import { PLATFORM_MAP } from "@/types/dashboard/platform"; +import type { + IPlatformConnectionItem, + TIntegrationProvider, + TPlatformConnectionStatus, +} from "@/types/integration/platformConnection"; + +import Badge, { type TBadgeVariant } from "@/components/common/badge/Badge"; +import Button from "@/components/common/button/Button"; + +import GoogleLogo from "@/assets/logo/social-logo/circle/google-circle.svg?react"; +import MetaLogo from "@/assets/logo/social-logo/circle/meta-circle.svg?react"; +import NaverLogo from "@/assets/logo/social-logo/circle/naver-circle.svg?react"; + +const PLATFORM_LOGOS: Record = { + GOOGLE: , + NAVER: , + META: , +}; + +const STATUS_LABEL: Record = { + disconnected: "미연동", + connected: "연동됨", + error: "연동 오류", + syncing: "동기화 중", +}; + +/** 안정=infoBlue · 주의=infoYellow · 위험=infoRed · 중립=surface (Badge variant 추가 없음) */ +const CONNECTION_STATUS_BADGE: Record< + TPlatformConnectionStatus, + TBadgeVariant +> = { + connected: "infoBlue", + syncing: "infoYellow", + error: "infoRed", + disconnected: "surface", +}; + +type TProps = IPlatformConnectionItem & { + onConnect?: () => void; + onReconnect?: () => void; + onDisconnect?: () => void; +}; + +function formatSyncedAt(iso?: string) { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function PlatformIntegrationCard({ + provider, + status, + lastSyncedAt, + errorMessage, + onConnect, + onReconnect, + onDisconnect, +}: TProps) { + const label = PLATFORM_MAP[provider] ?? provider; + const syncedLabel = formatSyncedAt(lastSyncedAt); + + return ( +
+
+
+
{PLATFORM_LOGOS[provider]}
+

+ {label} +

+
+ + {STATUS_LABEL[status]} + +
+ + {syncedLabel ? ( +

+ 마지막 동기화 · {syncedLabel} +

+ ) : null} + + {status === "error" && errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + +
+ +
+ {status === "disconnected" ? ( +

+ 광고 계정을 연동하면 대시보드와 캠페인에서 데이터를 확인할 수 + 있습니다. +

+ ) : null} + +
+ {status === "disconnected" ? ( + + ) : null} + + {status === "connected" ? ( + <> + + + + ) : null} + + {status === "error" ? ( + + ) : null} + + {status === "syncing" ? ( + + ) : null} +
+
+
+ ); +} + +export default memo(PlatformIntegrationCard); diff --git a/src/constants/sidebarNav.ts b/src/constants/sidebarNav.ts index 67ed1f5..ddae5a7 100644 --- a/src/constants/sidebarNav.ts +++ b/src/constants/sidebarNav.ts @@ -1,6 +1,7 @@ import type { INavItem } from "@/types/navigation/navItem"; import AdsIcon from "@/assets/icon/sidebar/ads.svg?react"; +import ConnectIcon from "@/assets/icon/sidebar/connect.svg?react"; import DashboardIcon from "@/assets/icon/sidebar/dashboard.svg?react"; import SettingsIcon from "@/assets/icon/sidebar/setting.svg?react"; import WorkspaceIcon from "@/assets/icon/sidebar/workspace.svg?react"; @@ -80,6 +81,12 @@ export const mainNav: INavItem[] = [ ]; export const footerNav: INavItem[] = [ + { + id: "integrations", + label: "플랫폼 연동", + icon: ConnectIcon, + path: "/integrations", + }, { id: "settings", label: "설정", diff --git a/src/layout/main/MainLayout.tsx b/src/layout/main/MainLayout.tsx index b3bb8d6..2900b24 100644 --- a/src/layout/main/MainLayout.tsx +++ b/src/layout/main/MainLayout.tsx @@ -199,13 +199,15 @@ export default function MainLayout() {
{headerRight}
-
- +
+
+ +
diff --git a/src/pages/integration/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx new file mode 100644 index 0000000..bb5ab97 --- /dev/null +++ b/src/pages/integration/PlatformIntegrationsPage.tsx @@ -0,0 +1,27 @@ +import { toast } from "sonner"; + +import PlatformIntegrationCard from "@/components/integration/PlatformIntegrationCard"; + +import { platformConnectionsMock } from "@/pages/integration/platformIntegrations.mock"; + +export default function PlatformIntegrationsPage() { + return ( +
+
    + {platformConnectionsMock.map((item) => ( +
  • + toast.message("연동하기 (mock)")} + onReconnect={() => toast.message("재연동 (mock)")} + onDisconnect={() => toast.message("연결 해제 (mock)")} + /> +
  • + ))} +
+
+ ); +} diff --git a/src/pages/integration/platformIntegrations.mock.ts b/src/pages/integration/platformIntegrations.mock.ts index 3558668..be67975 100644 --- a/src/pages/integration/platformIntegrations.mock.ts +++ b/src/pages/integration/platformIntegrations.mock.ts @@ -4,7 +4,6 @@ export const platformConnectionsMock: IPlatformConnectionItem[] = [ { provider: "META", status: "connected", - accountLabel: "Meta Ads · 메인 계정", lastSyncedAt: "2026-05-18T14:32:00", }, { @@ -14,7 +13,7 @@ export const platformConnectionsMock: IPlatformConnectionItem[] = [ { provider: "NAVER", status: "error", - accountLabel: "네이버 검색광고", errorMessage: "토큰이 만료되었습니다. 다시 연동해 주세요.", + lastSyncedAt: "2026-05-10T12:09:00", }, ]; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 964ffbb..e6cdee4 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -41,6 +41,9 @@ const MemberManagement = loadable( const Billing = loadable(lazy(() => import("@/pages/workspace/Billing"))); const Setting = loadable(lazy(() => import("@/pages/setting/Setting"))); +const PlatformIntegrationsPage = loadable( + lazy(() => import("@/pages/integration/PlatformIntegrationsPage")), +); const MainRoutes: RouteObject[] = [ { @@ -86,6 +89,10 @@ const MainRoutes: RouteObject[] = [ { path: "billing", element: }, ], }, + { + path: "integrations", + element: , + }, { path: "setting", element: , diff --git a/src/types/integration/platformConnection.ts b/src/types/integration/platformConnection.ts index 9659aec..5b988ba 100644 --- a/src/types/integration/platformConnection.ts +++ b/src/types/integration/platformConnection.ts @@ -11,8 +11,6 @@ export type TPlatformConnectionStatus = export interface IPlatformConnectionItem { provider: TIntegrationProvider; status: TPlatformConnectionStatus; - /** 연동된 광고 계정 표시명 */ - accountLabel?: string; /** ISO 문자열 또는 화면용 문자열 */ lastSyncedAt?: string; errorMessage?: string; From f3b4391f323c42aeaeef75b49694202713b92a4c Mon Sep 17 00:00:00 2001 From: YermIm Date: Sun, 24 May 2026 21:01:02 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20UI=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/integration/PlatformIntegrationsPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/integration/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx index bb5ab97..5264fb8 100644 --- a/src/pages/integration/PlatformIntegrationsPage.tsx +++ b/src/pages/integration/PlatformIntegrationsPage.tsx @@ -15,9 +15,9 @@ export default function PlatformIntegrationsPage() { > toast.message("연동하기 (mock)")} - onReconnect={() => toast.message("재연동 (mock)")} - onDisconnect={() => toast.message("연결 해제 (mock)")} + onConnect={() => toast.message("연동하기")} + onReconnect={() => toast.message("재연동")} + onDisconnect={() => toast.message("연결 해제")} /> ))} From ce93ffd98c33d2dc1b297d5c68fea5d92932376c Mon Sep 17 00:00:00 2001 From: YermIm Date: Sun, 24 May 2026 21:38:48 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=EC=97=90=20=EB=B1=83=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/PlatformIntegrationCard.tsx | 2 +- src/components/sidebar/Sidebar.tsx | 19 +++++++++ src/components/sidebar/SidebarItem.tsx | 41 +++++++++---------- .../integration/usePlatformConnections.ts | 25 +++++++++++ .../integration/PlatformIntegrationsPage.tsx | 8 ++-- 5 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 src/hooks/integration/usePlatformConnections.ts diff --git a/src/components/integration/PlatformIntegrationCard.tsx b/src/components/integration/PlatformIntegrationCard.tsx index bbe5009..b15b478 100644 --- a/src/components/integration/PlatformIntegrationCard.tsx +++ b/src/components/integration/PlatformIntegrationCard.tsx @@ -80,7 +80,7 @@ function PlatformIntegrationCard({ {STATUS_LABEL[status]} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index a6ca1ff..3084b7b 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -10,8 +10,14 @@ import { isPathMatch } from "@/utils/navigation/pathMatch"; import { applyWorkspacePathsToNav } from "@/utils/navigation/workspaceNavPaths"; import { useComingSoon } from "@/hooks/common/useComingSoon"; +import { + needsIntegrationAttention, + usePlatformConnections, +} from "@/hooks/integration/usePlatformConnections"; import { useSidebar } from "@/hooks/sidebar/useSidebar"; +import Badge from "@/components/common/badge/Badge"; + import { SidebarItem } from "./SidebarItem"; import { SubMenu } from "./SubMenu"; import { WorkspaceSwitcher } from "./WorkspaceSwitcher"; @@ -71,6 +77,12 @@ export default function Sidebar() { const { showComingSoon } = useComingSoon(); const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId); + const { data: platformConnections } = usePlatformConnections(); + const showIntegrationsAttention = useMemo( + () => needsIntegrationAttention(platformConnections), + [platformConnections], + ); + const mainNavWithWorkspace = useMemo( () => applyWorkspacePathsToNav(mainNav, selectedOrgId), [selectedOrgId], @@ -198,6 +210,13 @@ export default function Sidebar() { isCollapsed={isCollapsed} className="w-full h-full" onClick={handleFooterItemClick} + trailing={ + item.id === "integrations" && + showIntegrationsAttention && + !isCollapsed ? ( + 연동 필요 + ) : undefined + } /> ); diff --git a/src/components/sidebar/SidebarItem.tsx b/src/components/sidebar/SidebarItem.tsx index a416b57..4ec7084 100644 --- a/src/components/sidebar/SidebarItem.tsx +++ b/src/components/sidebar/SidebarItem.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, type ReactNode } from "react"; import { NavLink } from "react-router-dom"; import { twMerge } from "tailwind-merge"; @@ -10,6 +10,7 @@ interface ISidebarItemProps { isOpen?: boolean; className: string; onClick: (id: string, hasChildren: boolean) => void; + trailing?: ReactNode; } export const SidebarItem = memo(function SidebarItem({ @@ -18,30 +19,28 @@ export const SidebarItem = memo(function SidebarItem({ isOpen, className, onClick, + trailing, }: ISidebarItemProps) { const hasChildren = !!item.children?.length; const Icon = item.icon; - const content = ( -
- {Icon && ( - - )} - + const itemClassName = twMerge( + className, + "flex items-center", + isCollapsed ? "justify-center" : "", + ); + + const content = isCollapsed ? ( + Icon ? ( + + ) : null + ) : ( +
+ {Icon ? : null} + {item.label} + {trailing ? {trailing} : null}
); @@ -49,7 +48,7 @@ export const SidebarItem = memo(function SidebarItem({ return ( { if (e.defaultPrevented) return; onClick(item.id, hasChildren); @@ -65,7 +64,7 @@ export const SidebarItem = memo(function SidebarItem({ type="button" aria-haspopup={hasChildren ? "menu" : undefined} aria-expanded={hasChildren ? isOpen : undefined} - className={twMerge(className, "flex items-center text-left")} + className={twMerge(itemClassName, "text-left")} onClick={(e) => { if (e.defaultPrevented) return; onClick(item.id, hasChildren); diff --git a/src/hooks/integration/usePlatformConnections.ts b/src/hooks/integration/usePlatformConnections.ts new file mode 100644 index 0000000..c1a29d4 --- /dev/null +++ b/src/hooks/integration/usePlatformConnections.ts @@ -0,0 +1,25 @@ +import type { IPlatformConnectionItem } from "@/types/integration/platformConnection"; + +import { useCoreQuery } from "@/hooks/customQuery"; + +import { platformConnectionsMock } from "@/pages/integration/platformIntegrations.mock"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function needsIntegrationAttention( + items: IPlatformConnectionItem[] | undefined, +): boolean { + return items?.some((item) => item.status === "error") ?? false; +} + +export function usePlatformConnections() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + return useCoreQuery( + ["platform-connections", orgId], + async () => { + // TODO: GET /api/orgs/{orgId}/integrations + return platformConnectionsMock; + }, + { enabled: orgId != null }, + ); +} diff --git a/src/pages/integration/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx index 5264fb8..f2881b0 100644 --- a/src/pages/integration/PlatformIntegrationsPage.tsx +++ b/src/pages/integration/PlatformIntegrationsPage.tsx @@ -1,14 +1,16 @@ import { toast } from "sonner"; -import PlatformIntegrationCard from "@/components/integration/PlatformIntegrationCard"; +import { usePlatformConnections } from "@/hooks/integration/usePlatformConnections"; -import { platformConnectionsMock } from "@/pages/integration/platformIntegrations.mock"; +import PlatformIntegrationCard from "@/components/integration/PlatformIntegrationCard"; export default function PlatformIntegrationsPage() { + const { data: platformConnections = [] } = usePlatformConnections(); + return (
    - {platformConnectionsMock.map((item) => ( + {platformConnections.map((item) => (
  • Date: Sun, 24 May 2026 22:14:30 +0900 Subject: [PATCH 5/8] feat: skeleton ui --- .../skeleton/PlatformIntegrationsSkeleton.tsx | 49 +++++++++++++++++++ .../integration/usePlatformConnections.ts | 4 +- .../integration/PlatformIntegrationsPage.tsx | 49 +++++++++++++------ 3 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx diff --git a/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx b/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx new file mode 100644 index 0000000..3626132 --- /dev/null +++ b/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx @@ -0,0 +1,49 @@ +import { + Skeleton, + SkeletonCircle, +} from "@/components/common/skeleton/Skeleton"; + +const SKELETON_COUNT = 3; + +export function PlatformIntegrationCardSkeleton() { + return ( +
    +
    +
    + + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    + ); +} + +export default function PlatformIntegrationsPageSkeleton() { + return ( +
      + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( +
    • + +
    • + ))} +
    + ); +} diff --git a/src/hooks/integration/usePlatformConnections.ts b/src/hooks/integration/usePlatformConnections.ts index c1a29d4..0feb67f 100644 --- a/src/hooks/integration/usePlatformConnections.ts +++ b/src/hooks/integration/usePlatformConnections.ts @@ -17,7 +17,9 @@ export function usePlatformConnections() { return useCoreQuery( ["platform-connections", orgId], async () => { - // TODO: GET /api/orgs/{orgId}/integrations + await new Promise((resolve) => { + setTimeout(resolve, 800); + }); return platformConnectionsMock; }, { enabled: orgId != null }, diff --git a/src/pages/integration/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx index f2881b0..e42936f 100644 --- a/src/pages/integration/PlatformIntegrationsPage.tsx +++ b/src/pages/integration/PlatformIntegrationsPage.tsx @@ -3,27 +3,44 @@ import { toast } from "sonner"; import { usePlatformConnections } from "@/hooks/integration/usePlatformConnections"; import PlatformIntegrationCard from "@/components/integration/PlatformIntegrationCard"; +import PlatformIntegrationsPageSkeleton from "@/components/integration/skeleton/PlatformIntegrationsSkeleton"; export default function PlatformIntegrationsPage() { - const { data: platformConnections = [] } = usePlatformConnections(); + const { + data: platformConnections = [], + isLoading, + isError, + error, + } = usePlatformConnections(); return (
    -
      - {platformConnections.map((item) => ( -
    • - toast.message("연동하기")} - onReconnect={() => toast.message("재연동")} - onDisconnect={() => toast.message("연결 해제")} - /> -
    • - ))} -
    + {isLoading ? ( + + ) : isError ? ( +
    +

    + {error?.message ?? + "플랫폼 연동 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."} +

    +
    + ) : ( +
      + {platformConnections.map((item) => ( +
    • + toast.message("연동하기")} + onReconnect={() => toast.message("재연동")} + onDisconnect={() => toast.message("연결 해제")} + /> +
    • + ))} +
    + )}
    ); } From 6d7c294e599db6a388bce87efe35234bb2dc5e2f Mon Sep 17 00:00:00 2001 From: YermIm Date: Tue, 2 Jun 2026 21:27:24 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EC=84=B8?= =?UTF-8?q?=EB=B6=80=EC=82=AC=ED=95=AD=20=EB=B0=8F=20=EC=B6=94=ED=9B=84=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/PlatformIntegrationCard.tsx | 105 ++++++++++++--- .../integration/UpcomingPlatformCard.tsx | 95 +++++++++++++ .../skeleton/PlatformIntegrationsSkeleton.tsx | 6 +- .../integration/usePlatformConnections.ts | 6 +- .../integration/PlatformIntegrationsPage.tsx | 52 ++++--- .../integration/platformIntegrations.mock.ts | 32 +++-- src/types/integration/platformConnection.ts | 26 +++- src/utils/integration/mapPlatformAccounts.ts | 127 ++++++++++++++++++ 8 files changed, 399 insertions(+), 50 deletions(-) create mode 100644 src/components/integration/UpcomingPlatformCard.tsx create mode 100644 src/utils/integration/mapPlatformAccounts.ts diff --git a/src/components/integration/PlatformIntegrationCard.tsx b/src/components/integration/PlatformIntegrationCard.tsx index b15b478..5877b1a 100644 --- a/src/components/integration/PlatformIntegrationCard.tsx +++ b/src/components/integration/PlatformIntegrationCard.tsx @@ -7,6 +7,12 @@ import type { TPlatformConnectionStatus, } from "@/types/integration/platformConnection"; +import { + formatConnectionDate, + formatConnectionDateTime, + getTokenExpireTone, +} from "@/utils/integration/mapPlatformAccounts"; + import Badge, { type TBadgeVariant } from "@/components/common/badge/Badge"; import Button from "@/components/common/button/Button"; @@ -38,36 +44,98 @@ const CONNECTION_STATUS_BADGE: Record< disconnected: "surface", }; +const TOKEN_EXPIRE_TEXT: Record< + ReturnType, + string +> = { + default: "text-text-title", + warning: "text-info-yellow", + expired: "text-info-red/80", +}; + type TProps = IPlatformConnectionItem & { onConnect?: () => void; onReconnect?: () => void; onDisconnect?: () => void; }; -function formatSyncedAt(iso?: string) { - if (!iso) return null; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return iso; - return date.toLocaleString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); +function PlatformConnectionMeta({ + status, + syncedAt, + externalAccountId, + tokenExpireAt, +}: Pick< + IPlatformConnectionItem, + "status" | "syncedAt" | "externalAccountId" | "tokenExpireAt" +>) { + const syncedLabel = formatConnectionDateTime(syncedAt); + const expireLabel = formatConnectionDate(tokenExpireAt); + const expireTone = getTokenExpireTone(tokenExpireAt); + + return ( +
    + {status === "disconnected" ? ( + <> +

    + 마지막 동기화 · + +

    +

    + 연동 계정 · + +

    +

    + 토큰 만료 예정 · + +

    + + ) : ( + <> + {syncedLabel ? ( +

    + 마지막 동기화 · + {syncedLabel} +

    + ) : null} + + {externalAccountId ? ( +

    + 연동 계정 · + + {externalAccountId} + +

    + ) : null} + + {expireLabel ? ( +

    + 토큰 만료 예정 · + + {expireLabel} + +

    + ) : null} + + )} +
    + ); } function PlatformIntegrationCard({ provider, status, - lastSyncedAt, + syncedAt, + externalAccountId, + tokenExpireAt, errorMessage, onConnect, onReconnect, onDisconnect, }: TProps) { const label = PLATFORM_MAP[provider] ?? provider; - const syncedLabel = formatSyncedAt(lastSyncedAt); return (
    @@ -86,11 +154,12 @@ function PlatformIntegrationCard({
    - {syncedLabel ? ( -

    - 마지막 동기화 · {syncedLabel} -

    - ) : null} + {status === "error" && errorMessage ? (

    diff --git a/src/components/integration/UpcomingPlatformCard.tsx b/src/components/integration/UpcomingPlatformCard.tsx new file mode 100644 index 0000000..51f2b05 --- /dev/null +++ b/src/components/integration/UpcomingPlatformCard.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react"; + +import Badge from "@/components/common/badge/Badge"; +import Button from "@/components/common/button/Button"; + +import KakaoLogo from "@/assets/logo/social-logo/circle/kakao-circle.svg?react"; + +const UPCOMING_CARD_SHELL_CLASS = + "flex h-full min-h-70 w-full rounded-3xl bg-surface-100 p-8 shadow-Soft tablet:p-8"; +const UPCOMING_CARD_DISABLED_CLASS = + "pointer-events-none select-none opacity-70 grayscale"; + +type TProps = { + title: string; + badgeText: string; + description: string; + icon?: ReactNode; + disabled?: boolean; +}; + +export default function UpcomingPlatformCard({ + title, + badgeText, + description, + icon, + disabled = true, +}: TProps) { + return ( +

    +
    +
    +
    + {icon ?? ( + + ? + + )} +
    +

    + {title} +

    +
    + + + {badgeText} + +
    + +

    {description}

    + +
    + +
    +
    + ); +} + +export function KakaoUpcomingCard() { + return ( + } + /> + ); +} + +export function ComingSoonUpcomingCard() { + return ( +
    +
    +

    Coming soon…

    +
    +
    + ); +} diff --git a/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx b/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx index 3626132..7699c2a 100644 --- a/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx +++ b/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx @@ -19,8 +19,10 @@ export function PlatformIntegrationCardSkeleton() {
    -
    - +
    + + +
    diff --git a/src/hooks/integration/usePlatformConnections.ts b/src/hooks/integration/usePlatformConnections.ts index 0feb67f..acba219 100644 --- a/src/hooks/integration/usePlatformConnections.ts +++ b/src/hooks/integration/usePlatformConnections.ts @@ -1,8 +1,10 @@ import type { IPlatformConnectionItem } from "@/types/integration/platformConnection"; +import { mapPlatformAccountsToConnections } from "@/utils/integration/mapPlatformAccounts"; + import { useCoreQuery } from "@/hooks/customQuery"; -import { platformConnectionsMock } from "@/pages/integration/platformIntegrations.mock"; +import { platformAccountsApiMock } from "@/pages/integration/platformIntegrations.mock"; import useWorkspaceStore from "@/store/useWorkspaceStore"; export function needsIntegrationAttention( @@ -20,7 +22,7 @@ export function usePlatformConnections() { await new Promise((resolve) => { setTimeout(resolve, 800); }); - return platformConnectionsMock; + return mapPlatformAccountsToConnections(platformAccountsApiMock); }, { enabled: orgId != null }, ); diff --git a/src/pages/integration/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx index e42936f..072f12e 100644 --- a/src/pages/integration/PlatformIntegrationsPage.tsx +++ b/src/pages/integration/PlatformIntegrationsPage.tsx @@ -4,6 +4,10 @@ import { usePlatformConnections } from "@/hooks/integration/usePlatformConnectio import PlatformIntegrationCard from "@/components/integration/PlatformIntegrationCard"; import PlatformIntegrationsPageSkeleton from "@/components/integration/skeleton/PlatformIntegrationsSkeleton"; +import { + ComingSoonUpcomingCard, + KakaoUpcomingCard, +} from "@/components/integration/UpcomingPlatformCard"; export default function PlatformIntegrationsPage() { const { @@ -25,21 +29,39 @@ export default function PlatformIntegrationsPage() {

    ) : ( -
      - {platformConnections.map((item) => ( -
    • - toast.message("연동하기")} - onReconnect={() => toast.message("재연동")} - onDisconnect={() => toast.message("연결 해제")} - /> -
    • - ))} -
    + <> +
      + {platformConnections.map((item) => ( +
    • + toast.message("연동하기")} + onReconnect={() => toast.message("재연동")} + onDisconnect={() => toast.message("연결 해제")} + /> +
    • + ))} +
    + +
    +

    + 더 많은 플랫폼 연동을 준비하고 있어요. 지원 범위는 변경될 수 + 있습니다. +

    + +
      +
    • + +
    • +
    • + +
    • +
    +
    + )}
); diff --git a/src/pages/integration/platformIntegrations.mock.ts b/src/pages/integration/platformIntegrations.mock.ts index be67975..2017c7d 100644 --- a/src/pages/integration/platformIntegrations.mock.ts +++ b/src/pages/integration/platformIntegrations.mock.ts @@ -1,19 +1,29 @@ -import type { IPlatformConnectionItem } from "@/types/integration/platformConnection"; +import type { IPlatformAccountApi } from "@/types/integration/platformConnection"; -export const platformConnectionsMock: IPlatformConnectionItem[] = [ +import { mapPlatformAccountsToConnections } from "@/utils/integration/mapPlatformAccounts"; + +/** 목록 API `data.platformAccounts` mock */ +export const platformAccountsApiMock: IPlatformAccountApi[] = [ { + platformAccountId: 1, + externalAccountId: "act_2847193056", provider: "META", - status: "connected", - lastSyncedAt: "2026-05-18T14:32:00", - }, - { - provider: "GOOGLE", - status: "disconnected", + authType: "OAUTH", + status: "ACTIVE", + tokenExpireAt: "2026-08-18", + syncedAt: "2026-05-18T14:32:00", }, { + platformAccountId: 3, + externalAccountId: "naver-ad-882910", provider: "NAVER", - status: "error", - errorMessage: "토큰이 만료되었습니다. 다시 연동해 주세요.", - lastSyncedAt: "2026-05-10T12:09:00", + authType: "OAUTH", + status: "EXPIRED", + tokenExpireAt: "2026-05-20", + syncedAt: "2026-05-10T12:09:00", }, ]; + +export const platformConnectionsMock = mapPlatformAccountsToConnections( + platformAccountsApiMock, +); diff --git a/src/types/integration/platformConnection.ts b/src/types/integration/platformConnection.ts index 5b988ba..8076fdc 100644 --- a/src/types/integration/platformConnection.ts +++ b/src/types/integration/platformConnection.ts @@ -2,6 +2,26 @@ import type { TProvider } from "@/types/ads/campaign"; export type TIntegrationProvider = TProvider; +/** 목록 API `platformAccounts[].status` */ +export type TPlatformAccountApiStatus = "ACTIVE" | "EXPIRED" | "INACTIVE"; + +export type TPlatformAuthType = "OAUTH"; + +export interface IPlatformAccountApi { + platformAccountId: number; + externalAccountId: string; + provider: TIntegrationProvider; + authType: TPlatformAuthType; + status: TPlatformAccountApiStatus; + tokenExpireAt?: string; + syncedAt?: string; +} + +export interface IPlatformAccountsResponseData { + platformAccounts: IPlatformAccountApi[]; +} + +/** 카드 UI용 연동 상태 */ export type TPlatformConnectionStatus = | "disconnected" | "connected" @@ -11,7 +31,9 @@ export type TPlatformConnectionStatus = export interface IPlatformConnectionItem { provider: TIntegrationProvider; status: TPlatformConnectionStatus; - /** ISO 문자열 또는 화면용 문자열 */ - lastSyncedAt?: string; + platformAccountId?: number; + externalAccountId?: string; + syncedAt?: string; + tokenExpireAt?: string; errorMessage?: string; } diff --git a/src/utils/integration/mapPlatformAccounts.ts b/src/utils/integration/mapPlatformAccounts.ts new file mode 100644 index 0000000..e5c05da --- /dev/null +++ b/src/utils/integration/mapPlatformAccounts.ts @@ -0,0 +1,127 @@ +import type { + IPlatformAccountApi, + IPlatformConnectionItem, + TIntegrationProvider, + TPlatformConnectionStatus, +} from "@/types/integration/platformConnection"; + +const INTEGRATION_PROVIDERS: TIntegrationProvider[] = [ + "META", + "GOOGLE", + "NAVER", +]; + +const TOKEN_EXPIRE_WARNING_DAYS = 7; + +function parseDate(value: string): Date | null { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +export function isTokenExpired(tokenExpireAt?: string): boolean { + if (!tokenExpireAt) return false; + const expire = parseDate(tokenExpireAt); + if (!expire) return false; + return startOfDay(expire).getTime() < startOfDay(new Date()).getTime(); +} + +export function isTokenExpiringSoon(tokenExpireAt?: string): boolean { + if (!tokenExpireAt || isTokenExpired(tokenExpireAt)) return false; + const expire = parseDate(tokenExpireAt); + if (!expire) return false; + const diffMs = + startOfDay(expire).getTime() - startOfDay(new Date()).getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + return diffDays <= TOKEN_EXPIRE_WARNING_DAYS; +} + +export type TTokenExpireTone = "default" | "warning" | "expired"; + +export function getTokenExpireTone(tokenExpireAt?: string): TTokenExpireTone { + if (!tokenExpireAt) return "default"; + if (isTokenExpired(tokenExpireAt)) return "expired"; + if (isTokenExpiringSoon(tokenExpireAt)) return "warning"; + return "default"; +} + +export function formatConnectionDateTime(value?: string): string | null { + if (!value) return null; + const date = parseDate(value); + if (!date) return value; + if (value.includes("T")) { + return date.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +export function formatConnectionDate(value?: string): string | null { + if (!value) return null; + const date = parseDate(value); + if (!date) return value; + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +function mapApiStatusToUi( + account: IPlatformAccountApi, +): TPlatformConnectionStatus { + if (account.status === "EXPIRED" || isTokenExpired(account.tokenExpireAt)) { + return "error"; + } + if (account.status === "ACTIVE") { + return "connected"; + } + return "disconnected"; +} + +function mapAccountToConnection( + account: IPlatformAccountApi, +): IPlatformConnectionItem { + const status = mapApiStatusToUi(account); + const base: IPlatformConnectionItem = { + provider: account.provider, + status, + platformAccountId: account.platformAccountId, + externalAccountId: account.externalAccountId, + syncedAt: account.syncedAt, + tokenExpireAt: account.tokenExpireAt, + }; + + if (status === "error") { + return { + ...base, + errorMessage: "토큰이 만료되었습니다. 다시 연동해 주세요.", + }; + } + + return base; +} + +export function mapPlatformAccountsToConnections( + accounts: IPlatformAccountApi[], +): IPlatformConnectionItem[] { + return INTEGRATION_PROVIDERS.map((provider) => { + const account = accounts.find((item) => item.provider === provider); + if (!account) { + return { provider, status: "disconnected" }; + } + return mapAccountToConnection(account); + }); +} From 276e9557de3d12e610a652c0e53bf10278d44c92 Mon Sep 17 00:00:00 2001 From: YermIm Date: Sun, 7 Jun 2026 06:16:35 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/integration/PlatformIntegrationCard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/integration/PlatformIntegrationCard.tsx b/src/components/integration/PlatformIntegrationCard.tsx index a1b4883..c7f0bf1 100644 --- a/src/components/integration/PlatformIntegrationCard.tsx +++ b/src/components/integration/PlatformIntegrationCard.tsx @@ -33,7 +33,6 @@ const STATUS_LABEL: Record = { syncing: "동기화 중", }; -/** 안정=infoBlue · 주의=infoYellow · 위험=infoRed · 중립=surface (Badge variant 추가 없음) */ const CONNECTION_STATUS_BADGE: Record< TPlatformConnectionStatus, TBadgeVariant From e3a8a6b586abdc0b8a07bdfdb6222e1ab8df65d6 Mon Sep 17 00:00:00 2001 From: YermIm Date: Sun, 7 Jun 2026 18:14:49 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/PlatformIntegrationCard.tsx | 8 -------- .../integration/UpcomingPlatformCard.tsx | 2 +- src/types/integration/platformConnection.ts | 6 +----- src/utils/integration/mapPlatformAccounts.ts | 18 ++++++++++++++++++ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/components/integration/PlatformIntegrationCard.tsx b/src/components/integration/PlatformIntegrationCard.tsx index c7f0bf1..5190f52 100644 --- a/src/components/integration/PlatformIntegrationCard.tsx +++ b/src/components/integration/PlatformIntegrationCard.tsx @@ -30,7 +30,6 @@ const STATUS_LABEL: Record = { disconnected: "미연동", connected: "연동됨", error: "연동 오류", - syncing: "동기화 중", }; const CONNECTION_STATUS_BADGE: Record< @@ -38,7 +37,6 @@ const CONNECTION_STATUS_BADGE: Record< TBadgeVariant > = { connected: "infoBlue", - syncing: "infoYellow", error: "infoRed", disconnected: "surface", }; @@ -217,12 +215,6 @@ function PlatformIntegrationCard({ 재연동 ) : null} - - {status === "syncing" ? ( - - ) : null}
diff --git a/src/components/integration/UpcomingPlatformCard.tsx b/src/components/integration/UpcomingPlatformCard.tsx index 51f2b05..5ee3d3c 100644 --- a/src/components/integration/UpcomingPlatformCard.tsx +++ b/src/components/integration/UpcomingPlatformCard.tsx @@ -58,7 +58,7 @@ export default function UpcomingPlatformCard({

{description}

-
diff --git a/src/types/integration/platformConnection.ts b/src/types/integration/platformConnection.ts index 8076fdc..9080d6a 100644 --- a/src/types/integration/platformConnection.ts +++ b/src/types/integration/platformConnection.ts @@ -22,11 +22,7 @@ export interface IPlatformAccountsResponseData { } /** 카드 UI용 연동 상태 */ -export type TPlatformConnectionStatus = - | "disconnected" - | "connected" - | "error" - | "syncing"; +export type TPlatformConnectionStatus = "disconnected" | "connected" | "error"; export interface IPlatformConnectionItem { provider: TIntegrationProvider; diff --git a/src/utils/integration/mapPlatformAccounts.ts b/src/utils/integration/mapPlatformAccounts.ts index e5c05da..ff6280e 100644 --- a/src/utils/integration/mapPlatformAccounts.ts +++ b/src/utils/integration/mapPlatformAccounts.ts @@ -12,8 +12,26 @@ const INTEGRATION_PROVIDERS: TIntegrationProvider[] = [ ]; const TOKEN_EXPIRE_WARNING_DAYS = 7; +const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +/** date-only(YYYY-MM-DD)는 UTC가 아닌 로컬 달력 날짜로 파싱 */ function parseDate(value: string): Date | null { + const dateOnly = DATE_ONLY_PATTERN.exec(value); + if (dateOnly) { + const year = Number(dateOnly[1]); + const month = Number(dateOnly[2]); + const day = Number(dateOnly[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return date; + } + const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; }