앱 자격증명(THREADS_*)이 없으면 카드를 통째로 숨겼다. 근거는 "누를 수 없는 버튼을 세우지 않는다" 였는데, **이 기능을 만든 사람조차 "연동 버튼이 아예 안 보인다" 고 했다**(2026-09-14). 만든 사람이 못 찾으면 사장님은 더더욱 못 찾는다 — 숨기는 것과 "아직 준비 중" 은 다른 말이고, 화면은 그 둘을 구별해 말해야 한다. - 자리는 늘 보이고 **버튼만 비활성**이다. "연동 준비 중입니다. 열리면 여기서 계정을 연결합니다." - 통째로 접는 경우는 하나만 남겼다 — 상태를 아직 못 읽었을 때(로그인 직후 한순간). 그건 '준비 안 됨' 이 아니라 '모름' 이라 다르게 다뤄야 한다 검증: frontend tsc·eslint 통과. 번들에 새 문구가 실린 것과 `connection_enabled=false` 에서 비활성 상태로 그려지는 것을 확인 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
5.3 KiB
TypeScript
133 lines
5.3 KiB
TypeScript
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>
|
||
);
|
||
}
|