Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
224 changes: 224 additions & 0 deletions src/components/integration/PlatformIntegrationCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { memo, type ReactNode } from "react";

import { PLATFORM_MAP } from "@/types/dashboard/provider";
import type {
IPlatformConnectionItem,
TIntegrationProvider,
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";

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: "연동 오류",
};

const CONNECTION_STATUS_BADGE: Record<
TPlatformConnectionStatus,
TBadgeVariant
> = {
connected: "infoBlue",
error: "infoRed",
disconnected: "surface",
};

const TOKEN_EXPIRE_TEXT: Record<
ReturnType<typeof getTokenExpireTone>,
string
> = {
default: "text-text-title",
warning: "text-info-yellow",
expired: "text-info-red/80",
};

type TProps = IPlatformConnectionItem & {
onConnect?: () => void;
onReconnect?: () => void;
onDisconnect?: () => void;
};

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 (
<div className="flex w-full flex-col gap-2">
{status === "disconnected" ? (
<>
<p className="font-body2 text-text-muted/60">
<span>마지막 동기화 · </span>
<span className="text-text-muted/60">—</span>
</p>
<p className="font-body2 text-text-muted/60">
<span>연동 계정 · </span>
<span className="text-text-muted/60">—</span>
</p>
<p className="font-body2 text-text-muted/60">
<span>토큰 만료 예정 · </span>
<span className="text-text-muted/60">—</span>
</p>
</>
) : (
<>
{syncedLabel ? (
<p className="font-body2 text-text-muted">
<span>마지막 동기화 · </span>
<span className="text-text-title">{syncedLabel}</span>
</p>
) : null}

{externalAccountId ? (
<p className="min-w-0 font-body2 text-text-muted">
<span>연동 계정 · </span>
<span
className="truncate text-text-title"
title={externalAccountId}
>
{externalAccountId}
</span>
</p>
) : null}

{expireLabel ? (
<p className="font-body2 text-text-muted">
<span>토큰 만료 예정 · </span>
<span className={TOKEN_EXPIRE_TEXT[expireTone]}>
{expireLabel}
</span>
</p>
) : null}
</>
)}
</div>
);
}

function PlatformIntegrationCard({
provider,
status,
syncedAt,
externalAccountId,
tokenExpireAt,
errorMessage,
onConnect,
onReconnect,
onDisconnect,
}: TProps) {
const label = PLATFORM_MAP[provider] ?? provider;

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>

<PlatformConnectionMeta
status={status}
syncedAt={syncedAt}
externalAccountId={externalAccountId}
tokenExpireAt={tokenExpireAt}
/>

{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}
</div>
</div>
</div>
);
}

export default memo(PlatformIntegrationCard);
95 changes: 95 additions & 0 deletions src/components/integration/UpcomingPlatformCard.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
YermIm marked this conversation as resolved.
return (
<div
className={[
UPCOMING_CARD_SHELL_CLASS,
"flex-col gap-4",
disabled && UPCOMING_CARD_DISABLED_CLASS,
]
.filter(Boolean)
.join(" ")}
aria-disabled={disabled}
>
<div className="flex min-w-0 items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="shrink-0">
{icon ?? (
<span className="flex h-12 w-12 items-center justify-center rounded-3xl bg-surface-200 font-heading3 text-text-muted">
?
</span>
)}
</div>
<h3 className="min-w-0 truncate font-heading3 text-text-title">
{title}
</h3>
</div>

<Badge variant="surface" className="h-8 shrink-0 font-body2">
{badgeText}
</Badge>
</div>

<p className="font-body2 text-text-muted">{description}</p>

<div className="mt-auto flex w-full">
<Button type="button" size="big" fullWidth disabled={disabled}>
준비 중
</Button>
</div>
</div>
);
}

export function KakaoUpcomingCard() {
return (
<UpcomingPlatformCard
title="Kakao"
badgeText="준비 중"
description="연동을 검토 중이에요."
icon={<KakaoLogo className="h-12 w-12" />}
/>
);
}

export function ComingSoonUpcomingCard() {
return (
<div
className={[
UPCOMING_CARD_SHELL_CLASS,
"flex",
UPCOMING_CARD_DISABLED_CLASS,
].join(" ")}
aria-disabled
>
<div className="flex flex-1 items-center justify-center">
<p className="font-heading3 text-text-muted">Coming soon…</p>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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-2">
<Skeleton className="h-4 w-full max-w-56" />
<Skeleton className="h-4 w-full max-w-48" />
<Skeleton className="h-4 w-full max-w-40" />
</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>
);
}
Loading
Loading