Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/assets/icon/sidebar/connect.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
164 changes: 164 additions & 0 deletions src/components/integration/PlatformIntegrationCard.tsx
Original file line number Diff line number Diff line change
@@ -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<TIntegrationProvider, ReactNode> = {
GOOGLE: <GoogleLogo className="h-12 w-12" />,
NAVER: <NaverLogo className="h-12 w-12" />,
META: <MetaLogo className="h-12 w-12" />,
};

const STATUS_LABEL: Record<TPlatformConnectionStatus, string> = {
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 (
<div className="flex h-full min-h-70 w-full flex-col gap-5 rounded-3xl bg-surface-100 p-8 shadow-Soft">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="shrink-0">{PLATFORM_LOGOS[provider]}</div>
<h3 className="min-w-0 truncate font-heading3 text-text-title">
{label}
</h3>
</div>
<Badge
variant={CONNECTION_STATUS_BADGE[status]}
className="h-8 shrink-0 font-body2"
>
{STATUS_LABEL[status]}
</Badge>
</div>

{syncedLabel ? (
<p className="font-body2 text-text-muted">
마지막 동기화 · {syncedLabel}
</p>
) : null}

{status === "error" && errorMessage ? (
<p className="font-body2 text-info-red" role="alert">
{errorMessage}
</p>
) : null}

<div className="flex-1" aria-hidden />

<div className="mt-auto flex w-full flex-col gap-4">
{status === "disconnected" ? (
<p className="font-body2 text-text-muted/80">
광고 계정을 연동하면 대시보드와 캠페인에서 데이터를 확인할 수
있습니다.
</p>
) : null}

<div className="flex w-full flex-wrap gap-4">
{status === "disconnected" ? (
<Button type="button" size="big" fullWidth onClick={onConnect}>
연동하기
</Button>
) : null}

{status === "connected" ? (
<>
<Button
type="button"
variant="outline"
size="big"
className="min-w-0 flex-1"
onClick={onReconnect}
>
재연동
</Button>
<Button
type="button"
variant="dangerSoft"
size="big"
className="min-w-0 flex-1"
onClick={onDisconnect}
>
연결 해제
</Button>
</>
) : null}

{status === "error" ? (
<Button
type="button"
variant="outline"
size="big"
fullWidth
onClick={onReconnect}
>
재연동
</Button>
) : null}

{status === "syncing" ? (
<Button type="button" size="small" fullWidth disabled>
동기화 중…
</Button>
) : null}
</div>
</div>
</div>
);
}

export default memo(PlatformIntegrationCard);
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
Skeleton,
SkeletonCircle,
} from "@/components/common/skeleton/Skeleton";

const SKELETON_COUNT = 3;

export function PlatformIntegrationCardSkeleton() {
return (
<div
className="flex h-full min-h-70 w-full flex-col gap-5 rounded-3xl bg-surface-100 p-8 shadow-Soft"
aria-hidden
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<SkeletonCircle className="h-12 w-12 shrink-0" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-8 w-16 shrink-0 rounded-full" />
</div>

<div className="flex w-full flex-col gap-3">
<Skeleton className="h-4 w-full max-w-60" />
</div>

<div className="flex-1" aria-hidden />

<div className="mt-auto flex w-full flex-col gap-4">
<Skeleton className="h-14 w-full rounded-2xl" />
</div>
</div>
);
}

export default function PlatformIntegrationsPageSkeleton() {
return (
<ul
className="grid w-full min-w-0 list-none grid-cols-3 items-stretch gap-6 p-0 m-0 tablet:grid-cols-1"
aria-busy="true"
aria-label="플랫폼 연동 목록 로딩 중"
>
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
<li key={i} className="flex h-full min-h-0 w-full min-w-0">
<PlatformIntegrationCardSkeleton />
</li>
))}
</ul>
);
}
19 changes: 19 additions & 0 deletions src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -198,6 +210,13 @@ export default function Sidebar() {
isCollapsed={isCollapsed}
className="w-full h-full"
onClick={handleFooterItemClick}
trailing={
item.id === "integrations" &&
showIntegrationsAttention &&
!isCollapsed ? (
<Badge variant="infoRed">연동 필요</Badge>
) : undefined
}
/>
</div>
);
Expand Down
41 changes: 20 additions & 21 deletions src/components/sidebar/SidebarItem.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -10,6 +10,7 @@ interface ISidebarItemProps {
isOpen?: boolean;
className: string;
onClick: (id: string, hasChildren: boolean) => void;
trailing?: ReactNode;
}

export const SidebarItem = memo(function SidebarItem({
Expand All @@ -18,38 +19,36 @@ export const SidebarItem = memo(function SidebarItem({
isOpen,
className,
onClick,
trailing,
}: ISidebarItemProps) {
const hasChildren = !!item.children?.length;
const Icon = item.icon;

const content = (
<div
className={twMerge(
"flex items-center w-full",
isCollapsed ? "justify-center" : "gap-4",
)}
>
{Icon && (
<Icon
className={twMerge("h-6 w-6 shrink-0", isCollapsed ? "" : "ml-2")}
/>
)}
<span
className={twMerge(
"whitespace-nowrap transition-opacity duration-200",
isCollapsed ? "opacity-0 w-0 invisible" : "opacity-100 ml-0",
)}
>
const itemClassName = twMerge(
className,
"flex items-center",
isCollapsed ? "justify-center" : "",
);

const content = isCollapsed ? (
Icon ? (
<Icon className="h-6 w-6 shrink-0" aria-hidden />
) : null
) : (
<div className="flex min-w-0 w-full items-center gap-2">
{Icon ? <Icon className="ml-2 h-6 w-6 shrink-0" aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
{item.label}
</span>
{trailing ? <span className="ml-1 shrink-0">{trailing}</span> : null}
</div>
);
Comment thread
YermIm marked this conversation as resolved.

if (item.path) {
return (
<NavLink
to={item.path}
className={twMerge(className, "flex items-center")}
className={itemClassName}
onClick={(e) => {
if (e.defaultPrevented) return;
onClick(item.id, hasChildren);
Expand All @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/constants/sidebarNav.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -80,6 +81,12 @@ export const mainNav: INavItem[] = [
];

export const footerNav: INavItem[] = [
{
id: "integrations",
label: "플랫폼 연동",
icon: ConnectIcon,
path: "/integrations",
},
{
id: "settings",
label: "설정",
Expand Down
27 changes: 27 additions & 0 deletions src/hooks/integration/usePlatformConnections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 () => {
await new Promise((resolve) => {
setTimeout(resolve, 800);
});
return platformConnectionsMock;
},
{ enabled: orgId != null },
);
}
Loading
Loading