o2o-site-AEO/solution/frontend/src/features/social/SocialConnectionCard.tsx
hbyang a517439c7d [fix] solution/frontend: 연동 카드를 준비 전에도 보여준다 — 숨기면 기능이 없는 것처럼 보인다
앱 자격증명(THREADS_*)이 없으면 카드를 통째로 숨겼다. 근거는 "누를 수 없는 버튼을 세우지
않는다" 였는데, **이 기능을 만든 사람조차 "연동 버튼이 아예 안 보인다" 고 했다**(2026-09-14).
만든 사람이 못 찾으면 사장님은 더더욱 못 찾는다 — 숨기는 것과 "아직 준비 중" 은 다른 말이고,
화면은 그 둘을 구별해 말해야 한다.

- 자리는 늘 보이고 **버튼만 비활성**이다. "연동 준비 중입니다. 열리면 여기서 계정을 연결합니다."
- 통째로 접는 경우는 하나만 남겼다 — 상태를 아직 못 읽었을 때(로그인 직후 한순간).
  그건 '준비 안 됨' 이 아니라 '모름' 이라 다르게 다뤄야 한다

검증: frontend tsc·eslint 통과. 번들에 새 문구가 실린 것과 `connection_enabled=false` 에서
비활성 상태로 그려지는 것을 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:29:31 +09:00

133 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useCallback, useEffect, useState} from 'react';
import {Loader2, Link2, Unlink} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {socialApi, type SocialState} from './api';
/**
* Threads 계정 연동 — **'내 사이트' 화면에 한 자리**.
*
* ★ 왜 발행 모달이 아니라 여기인가
* 계정은 `user × provider` 단위다(표도 그렇게 생겼다 — `owner_social_accounts`).
* 연결 버튼이 사업장 안에 있으면 사장님은 **업장 수만큼 연결해야 하는 줄 안다.**
* 연결은 한 번, 게재는 사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
* 그래서 여기서 미리 연결해 두고, 발행한 뒤 사이트에서 [소개글 쓰기] 를 누른다.
*
* ★ 준비 전에도 **자리는 보여준다.** 단, 버튼은 죽여 둔다.
* 처음에는 `connection_enabled=false` 면 통째로 숨겼는데, 그러면 **기능이 없는 것처럼 보인다** —
* 이 기능을 만든 사람조차 "연동 버튼이 안 보인다" 고 했다(2026-09-14). 사장님은 더더욱 못 찾는다.
* 숨기는 것과 "아직 준비 중" 은 다른 말이고, 화면은 그 둘을 구별해 말해야 한다.
* 앱 자격증명이 없으면 눌러도 409 이므로(`SOCIAL_CONNECTION_DISABLED`) **버튼은 비활성**이다 —
* 보이되 눌리지 않고, 왜 아직인지 한 줄로 말한다(docs/SOCIAL.md '연동 준비').
*/
export function SocialConnectionCard() {
const [state, setState] = useState<SocialState | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try {
setState(await socialApi<SocialState>('/account'));
} catch {
// 연결 상태를 못 읽는 것은 사이트 목록을 못 보여줄 이유가 아니다 — 조용히 접는다.
setState(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// 상태를 아직 못 읽었을 때만 접는다(로그인 직후 한순간). '준비 안 됨' 과는 다르다.
if (!state) return null;
const ready = state.connection_enabled;
const account = state.account;
const needsReauth = account?.status === 'needs_reauth';
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError('');
try {
await fn();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '요청을 처리하지 못했습니다.');
} finally {
setBusy(false);
}
}
const connect = () =>
run(async () => {
const {url} = await socialApi<{url: string}>('/oauth/connect', {});
// 인가 화면은 Meta 쪽이다. 돌아오면 `/sites?social=…` 로 되돌아온다.
window.location.assign(url);
});
return (
<section className="mb-4 rounded-xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-bold">SNS 연동 · Threads</h2>
<p className="mt-1 text-xs text-muted-foreground">
{!ready
? '연동 준비 중입니다. 열리면 여기서 계정을 연결합니다.'
: account
? needsReauth
? '연결이 만료됐습니다. 다시 연결해야 게재할 수 있습니다.'
: '연결되어 있습니다. 사이트를 발행한 뒤 소개글을 써서 올릴 수 있습니다.'
: '미리 연결해 두면, 사이트를 발행한 뒤 소개글을 써서 이 계정으로 올립니다.'}
</p>
{account && (
<p className="mt-1 text-xs">
<a
href={account.profile_url}
target="_blank"
rel="noopener noreferrer"
className="font-bold underline underline-offset-4"
>
@{account.handle}
</a>
</p>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
size="sm"
variant={account && !needsReauth ? 'outline' : 'primary'}
disabled={busy || !ready}
onClick={connect}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Link2 className="size-4" />}
<span>{account ? '다시 연결' : 'Threads 계정 연결'}</span>
</Button>
{account && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => run(() => socialApi('/oauth/disconnect', {}))}
>
<Unlink className="size-4" />
<span>연결 해제</span>
</Button>
)}
</div>
</div>
{/* 게재가 아직 안 열린 상태를 숨기지 않는다 — 연결만 해 두고 기다리는 것도 사장님의 선택이다. */}
{ready && !state.posting_enabled && (
<p className="mt-3 text-xs text-muted-foreground">
자동 게재는 준비 중입니다. 지금은 소개글을 만들어 복사하거나 내용을 확인할 수 있습니다.
</p>
)}
{error && (
<p role="alert" className="mt-3 text-xs text-destructive">
{error}
</p>
)}
</section>
);
}