npm package: @ph-cms/client-sdk
Description: Unified PH-CMS Client SDK (React + Core)
Author: 854dev
Latest version: 0.1.23

# @ph-cms/client-sdk

PH-CMS 클라이언트 SDK — 브라우저 및 React 애플리케이션을 위한 통합 클라이언트.

**주요 기능:**

- 타입이 지정된 API 클라이언트
- Auth Provider 인터페이스 및 구현체 (`LocalAuthProvider`, `FirebaseAuthProvider`)
- React Context 및 Hooks
- React Query 통합
- 자동 토큰 갱신 (Proactive + Reactive)
- Firebase ↔ PH-CMS 인증 동기화
- 세분화된 인증 상태 관리 (`authStatus`)

## Installation

```bash
npm install @ph-cms/client-sdk
```

> `@tanstack/react-query`는 직접 의존성으로 포함되어 있어 별도 설치가 필요 없습니다.
> Firebase를 사용하는 경우 `firebase`를 별도로 설치하세요 (선택적 peer dependency).

---

## Type Imports

SDK에서 사용하는 모든 타입은 `@ph-cms/client-sdk`에서 직접 import할 수 있습니다.
`@ph-cms/api-contract`를 직접 참조할 필요 없이 SDK 하나로 모든 타입을 가져올 수 있습니다.

```ts
// SDK 내부 타입
import type {
  AuthProvider,
  AuthStatus,
  JwtPayload,
  PHCMSClientConfig,
  PHCMSContextType,
  PHCMSProviderProps,
  UseFirebaseAuthSyncOptions,
  UseFirebaseAuthSyncReturn,
  FirebaseAuthSyncProps,
} from '@ph-cms/client-sdk';

// API 도메인 타입 (api-contract에서 re-export)
import type {
  // Auth
  LoginRequest,
  RegisterRequest,
  RefreshTokenRequest,
  FirebaseExchangeRequest,
  AuthResponse,

  // User
  UserDto,

  // Channel
  ChannelDto,
  CheckHierarchyQuery,

  // Content
  ContentDto,
  ContentMediaDto,
  CreateContentRequest,
  UpdateContentRequest,
  CreateStampTourRequest,
  UpdateStampTourRequest,
  ListContentQuery,
  PagedContentListResponse,

  // Hierarchy / Policy
  HierarchySetDto,
  PermissionPolicySetDto,

  // Terms
  TermDto,

  // Geo
  GeoJSON,

  // Media
  MediaUploadTicketRequest,
  MediaUploadTicketResponse,
  MediaUploadTicketBatchRequest,
  MediaUploadTicketBatchResponse,

  // Common
  PagedResponse,
} from '@ph-cms/client-sdk';
```

타입들은 `src/types.ts`에 도메인별로 정리되어 있으므로, IDE의 자동완성에서 바로 확인할 수 있습니다.

---

## Authentication Architecture

### Overview

SDK의 인증 시스템은 세 개의 레이어로 구성됩니다:

| Layer | Component | Role |
|---|---|---|
| **Provider** | `LocalAuthProvider` / `FirebaseAuthProvider` | 토큰 저장·조회·갱신·삭제를 담당하는 저수준 어댑터 |
| **Module** | `AuthModule` (`client.auth`) | 서버 API 호출 (`login`, `loginWithFirebase`, `me`, `refresh`, `logout`) |
| **Hook / Context** | `PHCMSProvider`, `useAuth` | React 상태 관리 — `authStatus`, 로그인·로그아웃 액션 |

**PHCMSProvider** — `useQuery(['auth','me'])`를 `authProvider.hasToken()`이 `true`일 때만 실행하여 불필요한 401을 방지하고, context로 `{ user, authStatus, hasToken, ... }`을 제공합니다.

**useAuth()** — 각 액션은 서버 호출 → `provider.setTokens()` → `refreshUser()`(= `me()` 재조회) 순서로 동작합니다:

| 액션 | 서버 호출 | 후속 동작 |
|---|---|---|
| `login()` | `POST /api/auth/login` | `setTokens()` → `refreshUser()` |
| `loginWithFirebase()` | `POST /api/auth/firebase/exchange` | `setTokens()` → `refreshUser()` |
| `register()` | `POST /api/auth/register` | `setTokens()` → `refreshUser()` |
| `logout()` | `POST /api/auth/logout` | `provider.logout()` → `refreshUser()` (상태 초기화) |

### Authentication Lifecycle

#### 1. 초기 마운트 (비인증 상태)

```
App Mount
  → PHCMSProvider mount
    → authProvider.hasToken()  ← false (localStorage에 토큰 없음)
    → useQuery enabled: false  ← me() 호출하지 않음
    → authStatus: 'unauthenticated'
```

서버에 불필요한 401 요청을 보내지 않습니다.

#### 2. 초기 마운트 (기존 세션 복원)

```
App Mount
  → PHCMSProvider mount
    → authProvider.hasToken()  ← true (localStorage에 토큰 존재)
    → useQuery enabled: true
    → authStatus: 'loading'    ← 이 시점에 스플래시 화면 표시 가능
    → GET /api/auth/me
    → authStatus: 'authenticated', user: { ... }
```

#### 3. 로그인 (이메일/비밀번호)

```
user calls login({ email, password })
  → POST /api/auth/login
  → provider.setTokens(accessToken, refreshToken)
  → refreshUser()
    → GET /api/auth/me
    → authStatus: 'authenticated', user: { ... }
```

#### 4. 로그아웃

```
user calls logout()
  → provider.logout()           ← localStorage 토큰 삭제
  → POST /api/auth/logout       ← 서버 세션 무효화 (실패해도 무시)
  → refreshUser()
    → hasToken: false → queryData 초기화
    → authStatus: 'unauthenticated', user: null
```

### Token Refresh (자동 토큰 갱신)

SDK는 두 가지 방식으로 토큰을 자동 갱신합니다:

#### Proactive Refresh (사전 갱신)

Provider의 `getToken()` 호출 시 JWT의 `exp` 클레임을 검사합니다.
만료 임박 시 (`expiryBufferMs` 이내, 기본 60초) 서버에 갱신 요청을 보냅니다.

```
Request Interceptor
  → provider.getToken()
    → JWT exp 검사: 만료 임박?
      → Yes: tryRefresh() → POST /api/auth/refresh
              → 새 토큰 저장 → 새 accessToken 반환
      → No:  기존 accessToken 반환
  → Authorization: Bearer {token}
```

#### Reactive Refresh (401 대응 갱신)

서버가 401을 반환하면 interceptor가 자동으로 토큰을 갱신하고 원본 요청을 재시도합니다.

```
API Request → 401 Unauthorized
  → coordinatedRefresh(refreshToken)
    → POST /api/auth/refresh (with _skipAuth flag)
    → 성공: provider.setTokens() → 원본 요청 재시도
    → 실패: ApiError throw
```

**동시 요청 처리:** 여러 요청이 동시에 401을 받으면 하나의 refresh 요청만 실행되고,
나머지 요청은 큐에서 대기합니다 (de-duplication).

**순환 방지:** refresh 요청 자체는 `_skipAuth` 플래그가 붙어 request interceptor에서
토큰 첨부를 건너뛰므로, `getToken()` → `tryRefresh()` 재귀 호출이 발생하지 않습니다.

---

## Auth Providers

모든 Provider는 `BaseAuthProvider` 추상 클래스를 상속합니다.
공통 로직(토큰 저장, 갱신, de-duplication, localStorage 관리)은 `BaseAuthProvider`에 구현되어 있고,
각 Provider는 `getToken()`과 `logout()` 등 고유 로직만 오버라이드합니다.

### `LocalAuthProvider`

이메일/비밀번호 기반 인증에 사용합니다. 토큰을 `localStorage`에 저장합니다.

```ts
import { LocalAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';

const authProvider = new LocalAuthProvider('my_app_', {
  expiryBufferMs: 60_000, // 선택 — 만료 60초 전부터 갱신 시도 (기본값)
});

const client = new PHCMSClient({
  baseURL: 'https://api.example.com',
  auth: authProvider,
});
```

| 생성자 인자 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `storageKeyPrefix` | `string` | `'ph_cms_'` | `localStorage` 키 접두사 (`{prefix}access_token`, `{prefix}refresh_token`) |
| `options.expiryBufferMs` | `number` | `60_000` | 만료 몇 ms 전부터 토큰을 갱신할지 |

### `FirebaseAuthProvider`

Firebase Authentication과 연동합니다. PH-CMS 토큰이 없으면 Firebase ID 토큰으로 fallback합니다.

```ts
import { FirebaseAuthProvider, PHCMSClient } from '@ph-cms/client-sdk';
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';

const firebaseApp = initializeApp({ /* Firebase config */ });
const firebaseAuth = getAuth(firebaseApp);

const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_', {
  expiryBufferMs: 60_000,
});

const client = new PHCMSClient({
  baseURL: 'https://api.example.com',
  auth: authProvider,
});
```

| 생성자 인자 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `auth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
| `storageKeyPrefix` | `string` | `'ph_cms_fb_'` | `localStorage` 키 접두사 |
| `options.expiryBufferMs` | `number` | `60_000` | 만료 몇 ms 전부터 토큰을 갱신할지 |

**`getToken()` 동작:**

1. PH-CMS access token이 유효하면 → 그대로 반환
2. 만료 임박이면 → `tryRefresh()` 시도 → 성공하면 새 토큰 반환
3. PH-CMS 토큰이 없거나 갱신 실패 → Firebase ID 토큰으로 fallback

### `AuthProvider` Interface

커스텀 인증 Provider를 구현하려면 아래 인터페이스를 따릅니다.
`BaseAuthProvider`를 상속하면 대부분의 메서드가 이미 구현되어 있으므로, `type`과 `getToken()`만 구현하면 됩니다.

```ts
interface AuthProvider {
  type: 'FIREBASE' | 'LOCAL';
  getToken(): Promise<string | null>;
  hasToken(): boolean;
  getRefreshToken(): string | null;
  setRefreshFn(fn: (refreshToken: string) => Promise<{ accessToken: string; refreshToken: string }>): void;
  setTokens(accessToken: string, refreshToken: string): void;
  onTokenExpired(callback: () => Promise<void>): void;
  logout(): Promise<void>;
}
```

| 메서드 | 설명 |
|---|---|
| `hasToken()` | 토큰 존재 여부 (동기). React의 `useQuery` `enabled` 조건에 사용되므로 반드시 동기여야 합니다 |
| `getToken()` | 유효한 access token 반환. 만료 시 자동 갱신 시도 후 반환 |
| `getRefreshToken()` | 현재 refresh token 반환 |
| `setRefreshFn(fn)` | `PHCMSClient`가 생성 시 호출. Provider가 자체적으로 토큰을 갱신할 수 있게 함 |
| `setTokens(access, refresh)` | 새 토큰 쌍 저장 (login/refresh 성공 시 자동 호출) |
| `onTokenExpired(callback)` | 토큰 만료 + 갱신 실패 시 호출할 콜백 등록 |
| `logout()` | 세션 정리 (토큰 삭제) |

---

## React Usage

### Basic Setup

```tsx
import { PHCMSClient, LocalAuthProvider, PHCMSProvider } from '@ph-cms/client-sdk';

const authProvider = new LocalAuthProvider('my_app_');

const client = new PHCMSClient({
  baseURL: 'https://api.example.com',
  auth: authProvider,
});

export function App() {
  return (
    <PHCMSProvider client={client}>
      <YourComponents />
    </PHCMSProvider>
  );
}
```

`PHCMSProvider`는 내부적으로 `QueryClientProvider`를 포함합니다.
외부에서 직접 관리하는 `QueryClient`를 사용하려면 `queryClient` prop으로 전달합니다:

```tsx
import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient();

<PHCMSProvider client={client} queryClient={queryClient}>
  ...
</PHCMSProvider>
```

### Authentication Status (`authStatus`)

Context는 세분화된 인증 상태를 `authStatus`로 제공합니다:

| `authStatus` | `hasToken` | `user` | 의미 |
|---|---|---|---|
| `'unauthenticated'` | `false` | `null` | 미로그인 (토큰 없음) → 로그인 화면 표시 |
| `'loading'` | `true` | `null` | 세션 복원 중 (`me()` 호출 중) → 스플래시 화면 표시 |
| `'authenticated'` | `true` | `UserDto` | 인증 완료 → 메인 화면 표시 |
| `'unauthenticated'` | `true` | `null` | 토큰 있으나 `me()` 실패 → 재로그인 유도 |

```tsx
import { usePHCMSContext } from '@ph-cms/client-sdk';

function AppRouter() {
  const { authStatus, hasToken } = usePHCMSContext();

  switch (authStatus) {
    case 'loading':
      return <SplashScreen />;
    case 'authenticated':
      return <MainApp />;
    case 'unauthenticated':
      // hasToken이 true이면 토큰은 있었으나 만료/검증 실패
      if (hasToken) return <SessionExpiredScreen />;
      return <LoginScreen />;
  }
}
```

> `isAuthenticated`와 `isLoading`은 하위 호환을 위해 유지되지만,
> 내부적으로 `authStatus`에서 파생됩니다 (`isAuthenticated === authStatus === 'authenticated'`).

### Authentication with `useAuth`

`useAuth`는 인증 상태와 액션을 통합 제공하는 메인 훅입니다.

```tsx
import { useAuth } from '@ph-cms/client-sdk';

function AuthComponent() {
  const {
    // 상태
    user,              // UserDto | null
    isAuthenticated,   // boolean
    isLoading,         // boolean

    // 액션 (모두 Promise 반환)
    login,             // (data: LoginRequest) => Promise<AuthResponse>
    loginWithFirebase, // (data: FirebaseExchangeRequest) => Promise<AuthResponse>
    register,          // (data: RegisterRequest) => Promise<AuthResponse>
    loginAnonymous,    // (data?: AnonymousLoginRequest) => Promise<AuthResponse>
    upgradeAnonymous,  // (data: { email, password, display_name?, username? }) => Promise<UserDto>
    logout,            // () => Promise<void>

    // 뮤테이션 상태 (isPending, error 등)
    loginStatus,
    loginWithFirebaseStatus,
    registerStatus,
    loginAnonymousStatus,
    upgradeAnonymousStatus,
    logoutStatus,
  } = useAuth();
}
```

#### 이메일/비밀번호 로그인

```tsx
function LoginForm() {
  const { login, loginStatus, isAuthenticated, user } = useAuth();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  if (isAuthenticated) {
    return <p>Welcome, {user?.display_name}</p>;
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login({ email, password });
      // 성공 → 자동으로 me() 호출 → user, isAuthenticated 갱신
    } catch (error) {
      console.error('Login failed:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button type="submit" disabled={loginStatus.isPending}>
        {loginStatus.isPending ? '로그인 중...' : '로그인'}
      </button>
      {loginStatus.error && <p>{(loginStatus.error as Error).message}</p>}
    </form>
  );
}
```

#### Firebase 로그인

Firebase 로그인은 두 단계로 이루어집니다:
1. Firebase SDK로 인증하여 ID 토큰 획득
2. `loginWithFirebase()`로 PH-CMS 서버에 토큰 교환

```tsx
import { getAuth, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';

function FirebaseLoginButton() {
  const { loginWithFirebase, loginWithFirebaseStatus } = useAuth();

  const handleFirebaseLogin = async () => {
    const auth = getAuth();
    const result = await signInWithPopup(auth, new GoogleAuthProvider());
    const idToken = await result.user.getIdToken();

    await loginWithFirebase({ idToken });
    // 성공 → 자동으로 me() 호출 → 인증 상태 갱신
  };

  return (
    <button onClick={handleFirebaseLogin} disabled={loginWithFirebaseStatus.isPending}>
      {loginWithFirebaseStatus.isPending ? 'Firebase 로그인 중...' : 'Google 로그인'}
    </button>
  );
}
```

#### 익명 회원가입

이메일·비밀번호 없이 임시 계정을 생성합니다. 생성된 계정은 나중에 이메일 계정으로 전환할 수 있으며, 그 동안 쌓인 활동 히스토리(좋아요, 스탬프 등)는 그대로 유지됩니다.

두 가지 방식이 있으며 **결과는 동일**합니다.

---

**방식 1 — 순수 익명 (Firebase 없이)**

서버가 임시 계정(`provider: anonymous`)을 직접 생성합니다.

```tsx
function GuestButton() {
  const { loginAnonymous, loginAnonymousStatus } = useAuth();

  return (
    <button
      onClick={() => loginAnonymous()}
      disabled={loginAnonymousStatus.isPending}
    >
      {loginAnonymousStatus.isPending ? '입장 중...' : '게스트로 시작하기'}
    </button>
  );
}
```

`channelUid`는 `PHCMSProvider`에 설정된 값이 자동으로 사용됩니다. 직접 지정하려면 인자로 전달합니다:

```ts
await loginAnonymous({ channelUid: 'channel-uid', displayName: '손님1' });
```

---

**방식 2 — Firebase 익명 (`signInAnonymously` 연동)**

Firebase의 `signInAnonymously()`로 익명 인증을 먼저 수행한 뒤, 발급된 ID 토큰을 `loginAnonymous()`에 전달합니다. 서버는 이를 검증하여 `provider: firebase:anonymous` 계정을 생성합니다.

이 방식은 Firebase 익명 계정을 나중에 `linkWithCredential()` 등으로 실계정에 연결하는 Firebase 네이티브 플로우와 함께 사용할 때 적합합니다.

```tsx
import { getAuth, signInAnonymously } from 'firebase/auth';

function FirebaseGuestButton() {
  const { loginAnonymous, loginAnonymousStatus } = useAuth();

  const handleAnonymous = async () => {
    const firebaseAuth = getAuth();
    const { user } = await signInAnonymously(firebaseAuth);
    const firebaseIdToken = await user.getIdToken();

    await loginAnonymous({ firebaseIdToken });
    // 성공 → 자동으로 me() 호출 → 인증 상태 갱신
  };

  return (
    <button onClick={handleAnonymous} disabled={loginAnonymousStatus.isPending}>
      {loginAnonymousStatus.isPending ? '입장 중...' : '게스트로 시작하기'}
    </button>
  );
}
```

> **주의:** Firebase 익명 계정은 `POST /auth/firebase/exchange`(토큰 교환)로는 등록되지 않습니다.
> 신규 익명 유저는 반드시 `loginAnonymous({ firebaseIdToken })`을 통해 등록해야 합니다.
> `FirebaseAuthSync`를 사용하는 경우 이 분기가 자동으로 처리됩니다 ([Firebase Auth Sync](#firebase-auth-sync) 참고).

---

**익명 계정 → 이메일 계정 전환**

`upgradeAnonymous()`를 호출하면 user ID를 유지한 채로 정식 이메일 계정으로 전환됩니다.

```tsx
function UpgradeForm() {
  const { upgradeAnonymous, upgradeAnonymousStatus, user } = useAuth();

  const handleUpgrade = async (email: string, password: string, displayName: string) => {
    await upgradeAnonymous({ email, password, display_name: displayName });
    // 성공 → role에서 'anonymous' 제거됨 → 기존 히스토리 유지
  };

  if (!user?.role.includes('anonymous')) return null;

  return (
    <form onSubmit={e => {
      e.preventDefault();
      const fd = new FormData(e.currentTarget);
      handleUpgrade(
        fd.get('email') as string,
        fd.get('password') as string,
        fd.get('display_name') as string,
      );
    }}>
      <input name="email" type="email" placeholder="이메일" />
      <input name="password" type="password" placeholder="비밀번호" />
      <input name="display_name" placeholder="이름" />
      <button type="submit" disabled={upgradeAnonymousStatus.isPending}>
        {upgradeAnonymousStatus.isPending ? '전환 중...' : '계정 만들기'}
      </button>
    </form>
  );
}
```

---

#### 회원가입

```tsx
function RegisterForm() {
  const { register, registerStatus } = useAuth();

  const handleRegister = async (formData: {
    email: string;
    password: string;
    display_name: string;
    username?: string;
  }) => {
    await register(formData);
    // 성공 → 자동으로 me() 호출 → 즉시 인증 상태로 전환
  };

  // ...
}
```

#### 로그아웃

```tsx
function LogoutButton() {
  const { logout } = useAuth();

  return <button onClick={() => logout()}>로그아웃</button>;
}
```

### Context API (`usePHCMSContext`)

`useAuth` 대신 context에 직접 접근할 수도 있습니다.

```tsx
import { usePHCMSContext } from '@ph-cms/client-sdk';

function MyComponent() {
  const {
    client,          // PHCMSClient 인스턴스
    user,            // UserDto | null
    isAuthenticated, // boolean
    isLoading,       // boolean
    authStatus,      // 'loading' | 'authenticated' | 'unauthenticated'
    hasToken,        // boolean — Provider에 토큰이 존재하는지
    refreshUser,     // () => Promise<void> — 수동으로 프로필 다시 조회
  } = usePHCMSContext();

  const handleProfileUpdate = async () => {
    // 프로필 수정 API 호출 후
    await refreshUser(); // context 갱신
  };
}
```

### Profile Update (`useUpdateProfile`)

사용자가 자신의 프로필을 수정할 수 있는 훅입니다. 업데이트가 성공하면 내부적으로 `refreshUser()`가 호출되어 컨텍스트와 UI가 즉각적으로 갱신됩니다.

```tsx
import { useUpdateProfile } from '@ph-cms/client-sdk';

function ProfileEditor({ userUid }) {
  const { mutateAsync: updateProfile, isPending } = useUpdateProfile();

  const handleSave = async () => {
    await updateProfile({
      uid: userUid,
      data: {
        display_name: 'New Name',
        avatar_url: 'https://example.com/new-avatar.png',
        profile_data: { bio: 'Hello World' },
      }
    });
    alert('프로필이 업데이트되었습니다.');
  };

  return <button onClick={handleSave} disabled={isPending}>저장</button>;
}
```

## Using API Directly (Without Hooks)

React Hook을 사용하지 않는 환경(Vanilla JS, Node.js, 또는 React 컴포넌트 외부)에서도 `PHCMSClient`를 직접 사용하여 모든 기능을 이용할 수 있습니다.

### 1. Client 초기화

```ts
import { PHCMSClient, LocalAuthProvider } from '@ph-cms/client-sdk';

const authProvider = new LocalAuthProvider('my_app_');
const client = new PHCMSClient({
  baseURL: 'https://api.example.com',
  auth: authProvider,
});
```

### 2. 인증 및 사용자 정보

```ts
// 로그인 (토큰은 provider에 자동 저장됨)
const authResponse = await client.auth.login({
  email: 'user@example.com',
  password: 'password',
});

// 현재 사용자 프로필 조회
// channelUid를 넘기면 해당 채널에 대한 약관 준수 상태 등이 포함됩니다.
const me = await client.auth.me({ channelUid: 'my-channel-uid' });
console.log(`Hello, ${me.display_name}`);

// 로그아웃
await client.auth.logout();
```

### 3. 콘텐츠 및 채널 관리

```ts
// 콘텐츠 목록 조회
const posts = await client.content.list({
  channelUid: 'my-channel-uid',
  type: 'post',
  limit: 10
});

// 콘텐츠 상세 조회
const detail = await client.content.get('content-uid');

// 채널 정보 조회
const channel = await client.channel.getBySlug('my-channel-slug');
```

### 4. 약관 동의

```ts
// 채널별 약관 목록 조회
const terms = await client.terms.listByChannel('my-channel-uid');

// 약관 동의 제출
await client.terms.agree({
  channelUid: 'my-channel-uid',
  termCodes: ['service_terms_v1', 'privacy_policy_v1']
});
```

### 5. 토큰 수동 관리 (선택 사항)

일반적으로 SDK가 가로채기(Interceptor)를 통해 토큰을 자동으로 관리하지만, 직접 제어해야 할 경우 다음과 같이 사용합니다.

```ts
// 현재 저장된 리프레시 토큰 가져오기
const refreshToken = authProvider.getRefreshToken();

if (refreshToken) {
  // 토큰 갱신 요청
  const newTokens = await client.auth.refresh(refreshToken);
  
  // 새 토큰 저장
  authProvider.setTokens(newTokens.accessToken, newTokens.refreshToken);
}
```

### Legacy Hooks

하위 호환성을 위해 개별 훅도 제공됩니다. 새 코드에서는 `useAuth`를 권장합니다.

| Hook | 설명 |
|---|---|
| `useUser()` | `{ data: UserDto \| null, isLoading, isAuthenticated }` |
| `useLogin()` | `{ mutateAsync: login }` |
| `useLogout()` | `{ mutateAsync: logout }` |

### Firebase Auth Sync

Firebase 인증 상태가 변경될 때 PH-CMS 백엔드와 자동으로 동기화할 수 있습니다.

| 방식 | 용도 |
|---|---|
| `useFirebaseAuthSync` 훅 | 기존 컴포넌트에 동기화 로직을 삽입할 때 |
| `<FirebaseAuthSync>` 컴포넌트 | 컴포넌트 트리를 감싸서 선언적으로 사용할 때 |

#### 동기화 동작

```
Firebase onAuthStateChanged
  │
  ├─ fbUser 존재 + PH-CMS 비인증 상태
  │   → fbUser.getIdToken()
  │   │
  │   ├─ fbUser.isAnonymous === true
  │   │   → client.auth.loginAnonymous({ channelUid, firebaseIdToken })
  │   │       ※ POST /auth/anonymous  (등록 또는 기존 계정 토큰 재발급)
  │   │
  │   └─ fbUser.isAnonymous === false  (Google, 이메일 등)
  │       → client.auth.loginWithFirebase({ idToken })
  │           ※ POST /auth/firebase/exchange  (기존 유저 로그인 전용)
  │
  │   → provider.setTokens(...)
  │   → refreshUser()  ← me() 호출하여 프로필 로드
  │
  └─ fbUser null (로그아웃) + PH-CMS 인증 상태
      → client.auth.logout()
      → refreshUser()  ← 상태 초기화
```

> `POST /auth/firebase/exchange`는 **기존 유저 로그인 전용**입니다.
> 신규 익명 유저를 이 엔드포인트로 생성하려 하면 서버가 에러를 반환합니다.
> `FirebaseAuthSync`를 사용하면 익명/비익명 분기가 자동으로 처리됩니다.

#### `<FirebaseAuthSync>` 컴포넌트

`<PHCMSProvider>` 안에서 사용합니다:

```tsx
import { PHCMSProvider, FirebaseAuthSync } from '@ph-cms/client-sdk';
import { getAuth } from 'firebase/auth';

const firebaseAuth = getAuth(firebaseApp);

function App() {
  return (
    <PHCMSProvider client={client}>
      <FirebaseAuthSync firebaseAuth={firebaseAuth}>
        <MainContent />
      </FirebaseAuthSync>
    </PHCMSProvider>
  );
}
```

| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `firebaseAuth` | `firebase/auth.Auth` | (필수) | Firebase Auth 인스턴스 |
| `logoutOnFirebaseSignOut` | `boolean` | `true` | Firebase 로그아웃 시 PH-CMS도 자동 로그아웃할지 |
| `onSyncSuccess` | `() => void` | — | 토큰 교환 성공 시 콜백 |
| `onSyncError` | `(error: unknown) => void` | — | 토큰 교환 실패 시 콜백 |

#### `useFirebaseAuthSync` 훅

```tsx
import { useFirebaseAuthSync } from '@ph-cms/client-sdk';
import { getAuth } from 'firebase/auth';

const firebaseAuth = getAuth(firebaseApp);

function AppContent() {
  const { isSyncing } = useFirebaseAuthSync({
    firebaseAuth,
    onSyncSuccess: () => console.log('Firebase↔PH-CMS 동기화 완료'),
    onSyncError: (err) => console.error('동기화 실패:', err),
  });

  if (isSyncing) return <div>인증 동기화 중...</div>;

  return <MainContent />;
}
```

#### 전체 구성 예시 (Firebase)

```tsx
import {
  PHCMSClient,
  FirebaseAuthProvider,
  PHCMSProvider,
  FirebaseAuthSync,
} from '@ph-cms/client-sdk';
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';

const firebaseApp = initializeApp({ /* config */ });
const firebaseAuth = getAuth(firebaseApp);

const authProvider = new FirebaseAuthProvider(firebaseAuth, 'my_app_fb_');

const client = new PHCMSClient({
  baseURL: 'https://api.example.com',
  auth: authProvider,
});

function App() {
  return (
    <PHCMSProvider client={client}>
      <FirebaseAuthSync
        firebaseAuth={firebaseAuth}
        onSyncError={(err) => alert('인증 동기화에 실패했습니다.')}
      >
        <Router />
      </FirebaseAuthSync>
    </PHCMSProvider>
  );
}
```

이 구성에서는:
- Firebase 로그인 시 `FirebaseAuthSync`가 자동으로 PH-CMS 토큰 교환 + 프로필 조회를 수행
- Firebase 로그아웃 시 PH-CMS 세션도 자동 정리
- 앱 재방문 시 localStorage 토큰으로 세션 자동 복원
- 토큰 만료 시 자동 갱신 (proactive + reactive)

---

## Using Data Hooks

### Content

`useContentList`, `useCreateContent` 등 콘텐츠 관련 훅은 `PHCMSProvider`에 설정된 `channelUid`를 자동으로 사용합니다.

```tsx
import { useContentList } from '@ph-cms/client-sdk';

function ContentList() {
  // channelUid를 직접 넘기지 않아도 컨텍스트의 값을 사용합니다.
  const { data, isLoading } = useContentList({ limit: 10 });

  if (isLoading) return <div>Loading...</div>;

  return (
    <ul>
      {data?.content.map(item => (
        <li key={item.uid}>{item.title}</li>
      ))}
    </ul>
  );
}
```

---

## Like (좋아요)

콘텐츠에 좋아요를 남기고, 좋아요 상태를 확인할 수 있습니다.

### 좋아요 토글 (인증 필수)

```ts
// 모듈 직접 사용
const result = await client.content.toggleLike('content-uid');
// => { liked: true, likeCount: 42 }

// React Hook 사용 (Optimistic update 포함)
const { mutate: toggleLike } = useToggleLike();
toggleLike('content-uid');
```

### 좋아요 상태 확인 (인증 선택)

```ts
// 모듈 직접 사용
const status = await client.content.getLikeStatus('content-uid');
// => { liked: true }

// React Hook 사용
const { data: status } = useLikeStatus('content-uid');
console.log(status?.liked); // true or false
```

## Stamp Tour

스탬프 투어 기능을 사용하여 특정 지점(Marker) 방문을 인증하고 진행 현황을 조회하거나, 새로운 투어를 생성할 수 있습니다.

#### 스탬프 투어 생성 (인증 필수)

여러 개의 마커(Marker)를 묶어 새로운 스탬프 투어를 생성합니다.

```tsx
import { useCreateStampTour } from '@ph-cms/client-sdk';

function CreateTourForm() {
  const { mutateAsync: createTour, isPending } = useCreateStampTour();

  const handleCreate = async () => {
    await createTour({
      channelSlug: 'my-channel',
      parentUid: 'folder-uid',
      title: '서울 명소 투어',
      summary: '서울의 주요 명소를 방문하세요.',
      markerUids: ['marker-1-uid', 'marker-2-uid'],
      isActive: true,
      tags: ['서울', '여행'],
      startsAt: '2026-04-01T00:00:00Z',
      endsAt: '2026-06-30T23:59:59Z'
    });
    alert('투어가 생성되었습니다.');
  };

  return <button onClick={handleCreate} disabled={isPending}>생성</button>;
}
```

---

## User Terms (사용자 약관)

PH-CMS는 글로벌 약관과 채널별 약관을 지원하며, 사용자의 동의 여부와 최신 버전 준수 여부를 트래킹합니다.

### Channel Context Setup

특정 채널의 서비스라면 `PHCMSProvider`에 `channelUid`를 전달하여, 해당 채널의 약관 준수 상태를 자동으로 가져올 수 있습니다.

```tsx
<PHCMSProvider client={client} channelUid="my-channel-slug">
  <App />
</PHCMSProvider>
```

이렇게 설정하면 `useAuth()`나 `usePHCMSContext()`를 통해 가져오는 `user` 객체의 `channel_agreements` 필드에 해당 채널의 약관 요약 정보가 포함됩니다.

### Checking Compliance

사용자가 필수 약관에 모두 동의했는지, 혹은 약관이 개정되어 재동의가 필요한지 확인합니다.

```tsx
function TermsGuard({ children }) {
  const { user } = useAuth();
  
  // 현재 채널에 대한 동의 요약 찾기
  const compliance = user?.channel_agreements?.[0]; 

  if (compliance && !compliance.is_fully_compliant) {
    return <TermsAgreementModal summary={compliance} />;
  }

  return children;
}
```

### Hooks for Terms

#### 1. `useChannelTerms(channelUid)`
특정 채널에 할당된 약관 목록을 조회합니다.

```tsx
const { data: terms } = useChannelTerms('my-channel-slug');
```

#### 2. `useAgreeTerms()`
약관 동의를 제출합니다.

```tsx
const { mutateAsync: agree } = useAgreeTerms();

const handleAgree = async (termCodes: string[]) => {
  await agree({
    termCodes,
    channelUid: 'my-channel-slug' // 선택 사항 (Provider에 설정되어 있어도 명시 가능)
  });
};
```

---

#### 스탬프 투어 수정 (인증 필수)

기존 스탬프 투어의 정보나 마커 목록을 수정합니다. (참여자가 있는 경우 마커 목록 수정은 제한될 수 있습니다.)

```tsx
import { useUpdateStampTour } from '@ph-cms/client-sdk';

function UpdateTourForm({ tourUid }) {
  const { mutateAsync: updateTour, isPending } = useUpdateStampTour();

  const handleUpdate = async () => {
    await updateTour({
      uid: tourUid,
      data: {
        title: '수정된 투어 제목',
        isActive: false,
        markerUids: ['marker-1-uid', 'marker-3-uid']
      }
    });
    alert('투어가 수정되었습니다.');
  };

  return <button onClick={handleUpdate} disabled={isPending}>투어 수정</button>;
}
```

#### 스탬프 획득 (인증)

사용자의 현재 GPS 좌표를 전송하여 특정 투어 내의 마커 방문을 인증합니다.

```tsx
import { useStamp } from '@ph-cms/client-sdk';

function StampButton({ tourUid, markerUid }) {
  const { mutateAsync: collectStamp, isPending } = useStamp();

  const handleStamp = async () => {
    try {
      const result = await collectStamp({
        tourUid,
        markerUid,
        data: { lat: 37.5511, lng: 126.9882 }
      });
      
      console.log(`거리: ${result.distance}m`);
      if (result.isCompletion) {
        alert('축하합니다! 투어를 완주하셨습니다.');
      }
    } catch (error) {
      alert('스탬프를 획득할 수 없습니다. (너무 멀거나 이미 획득함)');
    }
  };

  return <button onClick={handleStamp} disabled={isPending}>스탬프 찍기</button>;
}
```

#### 클라이언트 위치 검증 (Geo)

스탬프를 획득하기 전 클라이언트 창에서 사용자의 현재 GPS 좌표와 마커 위치를 비교하여, 반경 내에 들어왔는지 선제적으로 검증할 수 있습니다. `checkStampAvailability` 함수와 지리 정보 훅 `useGeolocation`을 조합하여 다양한 검증 상태(`available`, `distance_far`, `not_stamp_location`, `checking_geo` 등)를 처리합니다.

```tsx
import { useGeolocation, checkStampAvailability } from '@ph-cms/client-sdk';

function StampLocationGuard({ marker, stampStatus, isAuthenticated }) {
  // 실시간 GPS 좌표 및 권한 상태 감지
  const { latitude, longitude, geoError } = useGeolocation();

  // 클라이언트 단에서 마커 접근 가능성 확인
  const availability = checkStampAvailability({
    markerUid: marker.uid,
    markerLocation: marker.location, // { latitude, longitude }
    stampStatus,                     // 내가 획득한 스탬프 정보 (StampStatusDto)
    isLoggedIn: isAuthenticated,     // 권한 상태 확인
    latitude,
    longitude,
    geoError,
    distanceThreshold: 40,           // 스탬프 획득 가능 반경 (단위: 미터)
  });

  return (
    <div>
      <p>상태: {availability.stateMessage}</p>
      <p>힌트: {availability.hintMessage}</p>
      <p>거리: {availability.distance ? `${availability.distance.toFixed(1)}m` : '알 수 없음'}</p>
      
      {availability.state === 'available' && (
        <button onClick={handleCollect}>스탬프 획득하기</button>
      )}
    </div>
  );
}
```

#### 투어 진행 현황 조회

특정 투어에 대해 내가 획득한 스탬프 목록과 완주 여부를 확인합니다.

```tsx
import { useStampStatus } from '@ph-cms/client-sdk';

function TourProgress({ tourUid }) {
  const { data: status, isLoading } = useStampStatus(tourUid);

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <p>진행도: {status?.collected_markers} / {status?.total_markers}</p>
      {status?.is_completed && <p>🏆 완주 완료!</p>}
    </div>
  );
}
```

#### 투어 전체 통계 (관리자용)

투어의 총 참여자 수, 완주율 등 관리용 데이터를 조회합니다. (적절한 권한 필요)

```tsx
import { useTourStats } from '@ph-cms/client-sdk';

function AdminStats({ tourUid }) {
  const { data: stats, isLoading } = useTourStats(tourUid);
  // ...
}
```

---

## Media & File Upload

미디어 업로드는 3단계로 진행됩니다: Ticket 발급 → S3 업로드 → Content 생성/수정.

```tsx
import { useMediaUploadTickets, useUploadToS3, useCreateContent } from '@ph-cms/client-sdk';

function MediaUploader() {
  const { mutateAsync: getTickets } = useMediaUploadTickets();
  const { mutateAsync: uploadToS3 } = useUploadToS3();
  const { mutateAsync: createContent } = useCreateContent();

  const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    // Step 1: Upload Ticket 발급 (Presigned URL)
    const tickets = await getTickets([{
      filename: file.name,
      contentType: file.type,
      fileSize: file.size,
    }]);

    const { mediaUid, uploadUrl } = tickets[0];

    // Step 2: S3에 파일 직접 업로드
    await uploadToS3({ url: uploadUrl, file });

    // Step 3: mediaUid로 Content 생성
    await createContent({
      type: 'post',
      title: 'Post with image',
      mediaAttachments: [mediaUid],
    });
  };

  return <input type="file" onChange={handleFileChange} />;
}
```

---

## Error Handling

SDK는 세 종류의 에러를 throw합니다:

| Error Class | 상황 | 주요 필드 |
|---|---|---|
| `ValidationError` | Zod 스키마 검증 실패 (클라이언트 측) | `errors: ZodIssue[]` |
| `ApiError` | 서버가 2xx 이외의 응답을 반환 | `statusCode: number`, `originalError: any` |
| `PHCMSError` | 네트워크 오류 등 기타 에러 | `message: string` |

```tsx
import { ApiError } from '@ph-cms/client-sdk';

try {
  await login({ email, password });
} catch (error) {
  if (error instanceof ApiError) {
    if (error.statusCode === 401) {
      console.error('잘못된 인증 정보입니다.');
    }
  }
}
```

---

### `PHCMSClient`

```ts
const client = new PHCMSClient({
  baseURL: string;       // 서버 URL (필수)
  apiPrefix?: string;    // API 경로 접두사 (기본값: '/api')
  auth?: AuthProvider;   // 인증 프로바이더
  timeout?: number;      // 요청 타임아웃 ms (기본값: 10000)
  axiosInstance?: AxiosInstance; // 직접 커스터마이징한 Axios 인스턴스 (선택 사항)
});

client.authProvider   // AuthProvider | undefined
client.axiosInstance  // AxiosInstance — 내부 axios 인스턴스 직접 접근
client.auth           // AuthModule
client.user           // UserModule
client.content        // ContentModule
client.channel        // ChannelModule
client.terms          // TermsModule
client.media          // MediaModule
```

`apiPrefix`는 생성 시 각 모듈에 직접 주입됩니다. 예를 들어 `apiPrefix: '/v2/api'`로 설정하면 모든 모듈의 요청 URL이 `/v2/api/contents/...`, `/v2/api/auth/...` 형태로 전송됩니다.

```ts
// 기본값: /api
const client = new PHCMSClient({ baseURL: 'https://api.example.com' });

// 커스텀 prefix
const client = new PHCMSClient({
  baseURL: 'https://api.example.com',
  apiPrefix: '/v2/api',
});
```

### 커스텀 Axios 인스턴스 사용 (서버 to 서버 등)

인증서(SSL)가 필요하거나 특수한 Agent 설정이 필요한 경우, 직접 생성한 Axios 인스턴스를 주입할 수 있습니다.

```ts
import axios from 'axios';
import https from 'https';
import { PHCMSClient } from '@ph-cms/client-sdk';

const customInstance = axios.create({
  httpsAgent: new https.Agent({
    rejectUnauthorized: false, // 혹은 인증서 설정
  }),
});

const client = new PHCMSClient({
  baseURL: 'https://internal-api.ph-cms.com',
  axiosInstance: customInstance,
});
```

#### React 없는 환경에서 사용 (Node.js, Next.js 서버 컴포넌트 등)

메인 엔트리포인트(`@ph-cms/client-sdk`)는 React Context(`PHCMSProvider` 등)를 포함하고 있어, React가 없는 환경에서 임포트하면 `TypeError: createContext is not a function` 오류가 발생합니다.

이 경우 React 의존성이 제거된 `/core` 서브패스를 사용하세요.

**`PHCMSClient`를 통해 사용 (권장)**

```ts
import { PHCMSClient } from '@ph-cms/client-sdk/core';

const client = new PHCMSClient({
  baseURL: 'https://api.ph-cms.com',
  apiPrefix: '/api', // 생략 시 기본값 '/api'
});

// apiPrefix가 각 모듈에 직접 주입되므로 URL이 올바르게 구성됩니다
const result = await client.content.list({ channelUid: 'my-channel' });
```

**모듈 클래스를 직접 인스턴스화하는 경우**

모듈 생성자의 두 번째 인자로 prefix를 전달합니다.

```ts
import axios from 'axios';
import { ContentModule } from '@ph-cms/client-sdk/core';

const axiosInstance = axios.create({ baseURL: 'https://api.ph-cms.com' });

// 두 번째 인자로 prefix 전달 (기본값: '/api')
const content = new ContentModule(axiosInstance, '/v2/api');

const result = await content.list({ channelUid: 'my-channel' });
// → GET https://api.ph-cms.com/v2/api/contents
```

`/core`에서 export되는 항목은 다음과 같습니다 (React 관련 항목 제외):

| 카테고리 | export 항목 |
|---|---|
| 클라이언트 | `PHCMSClient` |
| 모듈 | `ContentModule`, `AuthModule`, `UserModule`, `ChannelModule`, `MediaModule`, `TermsModule` |
| Auth 프로바이더 | `BaseAuthProvider`, `LocalAuthProvider`, `JwtUtils` |
| 타입/에러 | `PHCMSClientError`, 각종 DTO/Request 타입 |

> React 환경에서는 기존대로 `@ph-cms/client-sdk`에서 임포트하면 `PHCMSProvider`, hooks 등을 모두 사용할 수 있습니다.

### `UserModule` (`client.user`)

| 메서드 | 설명 |
|---|---|
| `updateProfile(uid: string, data: UpdateUserProfileRequest)` | 유저의 프로필 정보 업데이트 (일반 유저는 `UpdateUserProfileRequest` 필드만 허용) → `UserDto` |

### `AuthModule` (`client.auth`)

| 메서드 | 설명 |
|---|---|
| `login(data: LoginRequest)` | 이메일/비밀번호 로그인 → `AuthResponse` |
| `loginWithFirebase(data: FirebaseExchangeRequest)` | Firebase ID 토큰 교환 → `AuthResponse` |
| `register(data: RegisterRequest)` | 회원가입 → `AuthResponse` |
| `me(params?: { channelUid?: string })` | 현재 사용자 프로필 조회 → `UserDto` |
| `refresh(refreshToken: string)` | 토큰 갱신 → `{ accessToken, refreshToken }` |
| `logout()` | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |

### `ContentModule` (`client.content`)

| 메서드 | 설명 |
|---|---|
| `list(query: ListContentQuery)` | 콘텐츠 목록 조회 → `PagedContentListResponse` |
| `get(uid: string)` | 단일 콘텐츠 상세 조회 → `ContentDto` |
| `create(data: CreateContentRequest)` | 일반 콘텐츠 생성 → `ContentDto` |
| `update(uid: string, data: UpdateContentRequest)` | 콘텐츠 수정 → `ContentDto` |
| `delete(uid: string)` | 콘텐츠 삭제 |
| `createStampTour(data: CreateStampTourRequest)` | **스탬프 투어 생성** → `ContentDto` |
| `updateStampTour(uid: string, data: UpdateStampTourRequest)` | **스탬프 투어 수정** → `ContentDto` |
| `stamp(tourUid: string, markerUid: string, data: CollectStampRequest)` | 스탬프 획득 인증 → `{ isCompletion, distance }` |
| `getStampStatus(tourUid: string)` | 내 스탬프 획득 현황 조회 → `StampStatusDto` |
| `getTourStats(tourUid: string)` | 투어 전체 통계 조회 → `TourStatsDto` |
| `toggleLike(uid: string)` | 좋아요 토글 → `ToggleLikeResponse` |
| `getLikeStatus(uid: string)` | 내 좋아요 여부 확인 → `LikeStatusResponse` |

### `ChannelModule` (`client.channel`)

| 메서드 | 설명 |
|---|---|
| `getByUid(uid: string)` | UID로 채널 상세 조회 |
| `getBySlug(slug: string)` | 슬러그로 채널 상세 조회 |
| `update(uid: string, data: any)` | 채널 정보 수정 |
| `checkHierarchy(params: CheckHierarchyQuery)` | 하이어라키 규칙 체크 |

### `TermsModule` (`client.terms`)

| 메서드 | 설명 |
|---|---|
| `get(id: number)` | 약관 상세 조회 |
| `agree(data: { termCodes: string[]; channelUid?: string })` | 약관 동의 제출 |
| `listByChannel(channelUid: string)` | 채널별 약관 목록 조회 |

### JWT Utilities

클라이언트에서 토큰 상태를 확인할 수 있는 유틸리티입니다 (서명 검증은 하지 않음).

```ts
import {
  decodeJwtPayload,
  getTokenExpirationMs,
  isTokenExpired,
  getTokenTTL,
} from '@ph-cms/client-sdk';

const payload = decodeJwtPayload(token);   // JwtPayload | null
const expiresAt = getTokenExpirationMs(token); // number (ms) | null
const expired = isTokenExpired(token, 60_000); // boolean | null
const ttl = getTokenTTL(token);            // number (ms) | null
```

---

## License

MIT