npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ph-cms/client-sdk

v0.1.46

Published

Unified PH-CMS Client SDK (React + Core)

Downloads

1,214

Readme

@ph-cms/client-sdk 0.1.45

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

주요 기능:

  • 타입이 지정된 API 클라이언트
  • Auth Provider 인터페이스 및 구현체 (LocalAuthProvider, FirebaseAuthProvider, StaticAuthProvider)
  • React Context 및 Hooks
  • React Query 통합
  • 장소 검색 모듈 및 React hook
  • 자동 토큰 갱신 (Proactive + Reactive)
  • SSR (Server-Side Rendering) 지원 및 Cookie 연동 가능
  • Firebase ↔ PH-CMS 인증 동기화
  • 세분화된 인증 상태 관리 (authStatus)

Installation

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 하나로 모든 타입을 가져올 수 있습니다.

// 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,
  RegisterInput,           // register() 훅에 전달하는 유니온 타입 (method: 'email' | 'firebase')
  FirebaseRegisterRequest, // method: 'firebase' 방식의 페이로드 타입
  RefreshTokenRequest,
  FirebaseExchangeRequest,
  AuthResponse,

  // User
  UserDto,
  BlockUserRequest,
  BlockUserResponse,
  BlockedUserDto,
  FollowStatusResponse,
  FollowUserResponse,
  PagedBlockedUserListResponse,
  SuspendUserRequest,
  SuspendUserResponse,

  // Channel
  ChannelDto,
  CheckHierarchyQuery,

  // Content
  ContentDto,
  ContentMediaDto,
  CreateContentRequest,
  UpdateContentRequest,
  CreateStampTourRequest,
  UpdateStampTourRequest,
  ListContentQuery,
  ListLikersQuery,
  ListLikedContentQuery,
  PagedContentListResponse,
  ReportContentRequest,
  ReportContentResponse,

  // Hierarchy / Policy
  HierarchySetDto,
  PermissionPolicySetDto,

  // Terms
  TermDto,

  // Geo
  GeoJSON,

  // Media
  MediaUploadTicketRequest,
  MediaUploadTicketResponse,
  MediaUploadTicketBatchRequest,
  MediaUploadTicketBatchResponse,

  // Common
  PagedResponse,
  UserProfileDto,
  IntegratedPlace,
  PlaceSearchQuery,
  PlaceSearchResponse,
} from '@ph-cms/client-sdk';

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


Place Search

SDK에서 통합 장소 검색 API를 바로 호출할 수 있습니다.

Core Client

const result = await client.place.search({
  keyword: '강남역 맛집',
});

React Hook

import { usePlaceSearch } from '@ph-cms/client-sdk';

const { data, isLoading } = usePlaceSearch(
  { keyword: '강남역 맛집' },
  true,
);

응답의 각 장소 항목은 항상 동일한 키를 가집니다.

  • phoneNumber: string | null
  • link: string | null
  • photos: [] 포함 항상 존재
  • reviews: [] 포함 항상 존재

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, 로그인·로그아웃 액션 |

PHCMSProvideruseQuery(['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() (method: 'email') | POST /api/auth/register | setTokens()refreshUser() | | register() (method: 'firebase') | POST /api/auth/register (provider: firebase:password) | 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() 재귀 호출이 발생하지 않습니다.


Server-Side Rendering (SSR)

SDK는 SSR 환경(Next.js, Remix 등)에서 인증된 상태로 API를 호출할 수 있는 기능을 제공합니다. 브라우저의 쿠키를 서버가 읽어 SDK에 직접 주입하는 방식으로 작동합니다.

1. StaticAuthProvider

SSR 환경에서는 localStorage가 없으므로, 메모리에만 토큰을 들고 있는 StaticAuthProvider를 사용하거나 PHCMSClient 설정에 직접 토큰을 주입합니다.

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

// Next.js Server Component 또는 API Route 예시
export async function getServerSideProps({ req }) {
  // .env 등에 설정된 프리픽스를 가져옵니다 (기본값 phcms_)
  const prefix = process.env.NEXT_PUBLIC_PH_CMS_AUTH_STORAGE_PREFIX || "phcms_";
  
  const accessToken = req.cookies[`${prefix}access_token`];
  const refreshToken = req.cookies[`${prefix}refresh_token`];

  const client = new PHCMSClient({
    baseURL: process.env.API_URL,
    accessToken,   // 쿠키에서 뽑은 토큰 주입
    refreshToken,
  });

  // 이제 이 호출은 인증된 상태(Bearer 헤더 포함)로 서버에서 실행됩니다.
  const content = await client.content.get(uid);
  
  return { props: { content } };
}

2. Next.js Middleware 연동 (추천)

httpOnly 쿠키를 사용할 경우, 브라우저의 SDK가 리프레시 토큰에 접근할 수 없습니다. 이때 Next.js 미들웨어를 통해 토큰 갱신을 처리하면 매끄러운 UX를 제공할 수 있습니다.

// middleware.ts (Next.js)
import { NextResponse, NextRequest } from 'next/server';

export async function middleware(req: NextRequest) {
  const accessToken = req.cookies.get('access_token')?.value;
  const refreshToken = req.cookies.get('refresh_token')?.value;

  // 액세스 토큰은 만료되었으나 리프레시 토큰이 있는 경우
  if (!accessToken && refreshToken) {
    const res = await fetch(`${process.env.API_URL}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });

    if (res.ok) {
      const tokens = await res.json();
      const response = NextResponse.next();
      response.cookies.set('access_token', tokens.accessToken, { httpOnly: true, path: '/' });
      response.cookies.set('refresh_token', tokens.refreshToken, { httpOnly: true, path: '/' });
      return response;
    }
  }

  return NextResponse.next();
}

Auth Providers

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

LocalAuthProvider

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

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합니다.

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를 상속하면 대부분의 메서드가 이미 구현되어 있으므로, typegetToken()만 구현하면 됩니다.

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

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으로 전달합니다:

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() 실패 → 재로그인 유도 |

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 />;
  }
}

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

Authentication with useAuth

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

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: RegisterInput) => Promise<AuthResponse>  ← method: 'email' | 'firebase'
    loginAnonymous,    // (data?: AnonymousLoginRequest) => Promise<AuthResponse>
    upgradeAnonymous,  // (data: { email, password, display_name?, username?, phone_number? }) => Promise<UserDto>
    logout,            // () => Promise<void>

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

이메일/비밀번호 로그인

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 서버에 토큰 교환
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)을 직접 생성합니다.

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

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

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

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

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

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

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

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 참고).


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

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

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

  const handleUpgrade = async (email: string, password: string, displayName: string, phoneNumber?: string) => {
    await upgradeAnonymous({ email, password, display_name: displayName, phone_number: phoneNumber });
    // 성공 → role이 ['user']로 전환됨 → 기존 히스토리 유지
  };

  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,
        fd.get('phone_number') as string || undefined,
      );
    }}>
      <input name="email" type="email" placeholder="이메일" />
      <input name="password" type="password" placeholder="비밀번호" />
      <input name="display_name" placeholder="이름" />
      <input name="phone_number" placeholder="전화번호 (선택)" />
      <button type="submit" disabled={upgradeAnonymousStatus.isPending}>
        {upgradeAnonymousStatus.isPending ? '전환 중...' : '계정 만들기'}
      </button>
    </form>
  );
}

회원가입

register()method 필드로 이메일 방식과 Firebase 방식을 구분합니다. channelUid 또는 channelSlug 중 하나는 필수입니다 (PHCMSProvider에 설정된 값이 자동으로 사용됩니다).

import type { RegisterInput } from '@ph-cms/client-sdk';

방식 1 — 이메일/비밀번호 (method: 'email' 또는 생략)

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

  const handleRegister = async () => {
    await register({
      // method: 'email',  // 생략 가능 (기본값)
      email: '[email protected]',
      password: 'password123',
      display_name: '홍길동',
      phone_number: '010-1234-5678', // 선택 사항
      termCodes: ['SERVICE_TERM', 'PRIVACY_TERM'], // 동의할 약관 코드 목록
      // channelUid는 PHCMSProvider에 설정된 값이 자동 사용됨
      // 명시적으로 지정하려면: channelUid: 'my-channel'
    });
    // 성공 → 자동으로 me() 호출 → 즉시 인증 상태로 전환
  };

  return (
    <button onClick={handleRegister} disabled={registerStatus.isPending}>
      {registerStatus.isPending ? '가입 중...' : '회원가입'}
    </button>
  );
}

방식 2 — Firebase 회원가입 (method: 'firebase')

서버가 Firebase 계정 생성과 PH-CMS 계정 생성을 하나의 트랜잭션으로 처리합니다. DB 트랜잭션 실패 시 서버가 Firebase 계정을 즉시 롤백하므로 고아 계정이 발생하지 않습니다.

클라이언트는 Firebase ID 토큰 없이 이메일/비밀번호만 전송합니다.

function FirebaseRegisterForm() {
  const { register, registerStatus } = useAuth();

  const handleRegister = async () => {
    await register({
      method: 'firebase',
      email: '[email protected]',
      password: 'password123',
      display_name: '홍길동',
      phone_number: '010-1234-5678', // 선택 사항
      termCodes: ['SERVICE_TERM', 'PRIVACY_TERM'],
      // channelUid는 PHCMSProvider에 설정된 값이 자동 사용됨
    });
    // 성공 → Firebase 계정 + PH-CMS 계정 동시 생성
    //       → 자동으로 me() 호출 → 즉시 인증 상태로 전환
  };

  return (
    <button onClick={handleRegister} disabled={registerStatus.isPending}>
      {registerStatus.isPending ? '가입 중...' : 'Firebase로 회원가입'}
    </button>
  );
}

Firebase 방식과 loginWithFirebase()의 차이

| | register({ method: 'firebase' }) | loginWithFirebase({ idToken }) | |---|---|---| | 목적 | 신규 가입 | 기존 Firebase 계정으로 로그인 / 신규 가입 | | 클라이언트 역할 | 이메일·비밀번호만 전송 | Firebase SDK로 직접 인증 후 ID 토큰 전달 | | Firebase 계정 생성 | 서버가 처리 (롤백 보장) | 클라이언트가 처리 | | 용도 | 서버 사이드 Firebase 가입 플로우 | 소셜 로그인(Google 등) 또는 자체 Firebase 로그인 |

로그아웃

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

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

Context API (usePHCMSContext)

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

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 갱신
  };
}

Liked Contents (useLikedContents)

특정 사용자가 좋아요를 누른 콘텐츠 목록을 조회합니다.

import { useLikedContents } from '@ph-cms/client-sdk';

function UserLikedContents({ userUid }) {
  const { data, isLoading } = useLikedContents(userUid, { type: 'post', page: 1, limit: 10 });

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

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

Public Profile (useUserProfile)

다른 사용자의 공개 프로필(이름, 아바타, 자기소개, 팔로워 수 등)을 조회할 때 사용하는 훅입니다.

import { useUserProfile } from '@ph-cms/client-sdk';

function UserProfileCard({ userId }) {
  const { data: profile, isLoading, error } = useUserProfile(userId);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>User not found</div>;

  return (
    <div>
      <img src={profile.avatar_url} alt={profile.display_name} />
      <h3>{profile.display_name} (@{profile.username})</h3>
      <p>followers: {profile.follower_count}</p>
      <p>{profile.profile_data?.bio}</p>
    </div>
  );
}

Follow Status / Follow / Unfollow

특정 사용자에 대한 팔로우 상태를 조회하고, 팔로우/언팔로우할 수 있습니다.

import {
  useFollowStatus,
  useFollowUser,
  useUnfollowUser,
} from '@ph-cms/client-sdk';

function FollowButton({ userUid }: { userUid: string }) {
  const { data: status, isLoading } = useFollowStatus(userUid);
  const { mutate: followUser, isPending: isFollowingPending } = useFollowUser();
  const { mutate: unfollowUser, isPending: isUnfollowingPending } = useUnfollowUser();

  if (isLoading) return <button disabled>Loading...</button>;

  const isFollowing = status?.is_following ?? false;
  const isPending = isFollowingPending || isUnfollowingPending;

  return (
    <button
      disabled={isPending}
      onClick={() => {
        if (isFollowing) unfollowUser(userUid);
        else followUser(userUid);
      }}
    >
      {isFollowing ? '언팔로우' : '팔로우'}
    </button>
  );
}

Followers / Followings List

특정 사용자의 팔로워 목록/팔로잉 목록을 페이지네이션으로 조회할 수 있습니다.

import { useFollowers, useFollowings } from '@ph-cms/client-sdk';

function FollowLists({ userUid }: { userUid: string }) {
  const { data: followers, isLoading: isFollowersLoading } = useFollowers(userUid, { page: 1, limit: 20 });
  const { data: followings, isLoading: isFollowingsLoading } = useFollowings(userUid, { page: 1, limit: 20 });

  if (isFollowersLoading || isFollowingsLoading) return <div>Loading...</div>;

  return (
    <div>
      <p>followers: {followers?.total ?? 0}</p>
      <p>followings: {followings?.total ?? 0}</p>
    </div>
  );
}

useLikedStats (Liked Statistics)

특정 사용자가 좋아요를 누른 콘텐츠의 타입별 통계를 조회합니다.

import { useLikedStats } from '@ph-cms/client-sdk';

function UserLikedStats({ userUid }) {
  const { data: stats, isLoading } = useLikedStats(userUid);

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

  return (
    <ul>
      {stats?.map(item => (
        <li key={item.type}>{item.type}: {item.count}</li>
      ))}
    </ul>
  );
}

### 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 초기화

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. 인증 및 사용자 정보

// 로그인 (토큰은 provider에 자동 저장됨)
const authResponse = await client.auth.login({
  email: '[email protected]',
  password: 'password',
});

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

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

3. 콘텐츠 및 채널 관리

// 콘텐츠 목록 조회
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. 약관 동의

// 채널별 약관 목록 조회
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)를 통해 토큰을 자동으로 관리하지만, 직접 제어해야 할 경우 다음과 같이 사용합니다.

// 현재 저장된 리프레시 토큰 가져오기
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> 안에서 사용합니다:

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

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)

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를 자동으로 사용합니다.

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 (좋아요)

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

좋아요 토글 (인증 필수)

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

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

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

// 모듈 직접 사용
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

좋아요 관련 목록 조회 (Public)

특정 콘텐츠를 좋아하는 사용자 목록이나, 특정 사용자가 좋아하는 콘텐츠 목록을 조회할 수 있습니다.

// 1. 특정 콘텐츠에 좋아요를 누른 사용자 목록
const likers = await client.content.getLikers('content-uid', { page: 1, limit: 10 });
// => PagedResponse<UserProfileDto>

// 2. 특정 사용자가 좋아요를 누른 콘텐츠 목록 (타입 필터링 지원)
const likedContents = await client.user.getLikedContents('user-uid', { type: 'post', page: 1 });
// => PagedContentListResponse

// 3. 특정 사용자가 좋아요를 누른 콘텐츠 타입별 통계
const likedStats = await client.user.getLikedStats('user-uid');
// => [{ type: 'post', count: 5 }, { type: 'place', count: 2 }]

Content Report (콘텐츠 신고)

부적절한 콘텐츠를 신고할 수 있습니다. (인증 필수)

// 모듈 직접 사용
const result = await client.content.report('content-uid', {
  reason: 'SPAM',
  description: '이 콘텐츠는 스팸입니다.'
});
// => { message: 'Report submitted successfully' }

// React Hook 사용
const { mutate: reportContent } = useReportContent();

reportContent({
  uid: 'content-uid',
  data: {
    reason: 'INAPPROPRIATE',
    description: '부적절한 내용이 포함되어 있습니다.'
  }
});

User Block (사용자 차단)

특정 사용자를 차단하거나 차단 해제할 수 있습니다. (인증 필수)

// 1. 사용자 차단
const blockResult = await client.user.block('target-user-uid', {
  reason: '부적절한 언행'
});
// => { success: true, message: 'User blocked' }

// React Hook 사용
const { mutate: blockUser } = useBlockUser();
blockUser({ uid: 'target-user-uid', data: { reason: '사유' } });

// 2. 사용자 차단 해제
const unblockResult = await client.user.unblock('target-user-uid');
// => { success: true, message: 'User unblocked' }

// React Hook 사용
const { mutate: unblockUser } = useUnblockUser();
unblockUser('target-user-uid');

// 3. 차단한 사용자 목록 조회
const blockedUsers = await client.user.getBlockedUsers('my-user-uid', { page: 1, limit: 10 });
// => PagedBlockedUserListResponse

// React Hook 사용
const { data: blockedList } = useBlockedUsers('my-user-uid', { page: 1 });

User Follow (사용자 팔로우)

특정 사용자를 팔로우하거나 언팔로우할 수 있습니다. (인증 필수)

// 1. 팔로우 상태 조회
const followStatus = await client.user.getFollowStatus('target-user-uid');
// => { is_following: false }

// React Hook 사용
const { data: status } = useFollowStatus('target-user-uid');

// 2. 사용자 팔로우
const followResult = await client.user.follow('target-user-uid');
// => { success: true, message: 'User followed', is_following: true }

// React Hook 사용
const { mutate: followUser } = useFollowUser();
followUser('target-user-uid');

// 3. 사용자 언팔로우
const unfollowResult = await client.user.unfollow('target-user-uid');
// => { success: true, message: 'User unfollowed', is_following: false }

// React Hook 사용
const { mutate: unfollowUser } = useUnfollowUser();
unfollowUser('target-user-uid');

// 4. 사용자 팔로워 목록 조회 (Public)
const followers = await client.user.getFollowers('target-user-uid', { page: 1, limit: 20 });
// => PagedUserProfileListResponse

// React Hook 사용
const { data: followerList } = useFollowers('target-user-uid', { page: 1, limit: 20 });

// 5. 사용자 팔로잉 목록 조회 (Public)
const followings = await client.user.getFollowings('target-user-uid', { page: 1, limit: 20 });
// => PagedUserProfileListResponse

// React Hook 사용
const { data: followingList } = useFollowings('target-user-uid', { page: 1, limit: 20 });

Global Ban (글로벌 밴 - 관리자 전용)

특정 사용자의 활동을 전역적으로 정지시키거나 해제할 수 있습니다. (관리자 권한 필수)

// 1. 사용자 활동 정지 (Suspend)
const suspendResult = await client.user.suspend('target-user-uid', {
  reason: '운영 정책 위반'
});
// => { success: true, message: 'User suspended' }

// React Hook 사용
const { mutate: suspendUser } = useSuspendUser();
suspendUser({ uid: 'target-user-uid', data: { reason: '사유' } });

// 2. 사용자 활동 정지 해제 (Unsuspend)
const unsuspendResult = await client.user.unsuspend('target-user-uid');
// => { success: true, message: 'User unsuspended' }

// React Hook 사용
const { mutate: unsuspendUser } = useUnsuspendUser();
unsuspendUser('target-user-uid');

Stamp Tour

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

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

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

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

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

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

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

Checking Compliance

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

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)

특정 채널에 할당된 약관 목록을 조회합니다.

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

2. useAgreeTerms()

약관 동의를 제출합니다.

const { mutateAsync: agree } = useAgreeTerms();

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

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

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

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 좌표를 전송하여 특정 투어 내의 마커 방문을 인증합니다.

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 등)를 처리합니다.

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>
  );
}

투어 진행 현황 조회

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

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>
  );
}

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

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

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

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

Media & File Upload

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

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 |

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

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

await client.getToken() // 현재 유효한 PH-CMS access token 반환 (만료 시 자동 갱신, 없으면 null)

getToken()은 PH-CMS SDK가 아닌 외부 서비스(예: 알림 서비스)를 클라이언트에서 직접 호출할 때 Authorization 헤더에 넣을 토큰을 가져오는 데 사용합니다.

const token = await client.getToken();

const res = await fetch(`${NOTIFICATION_SERVICE_URL}/notifications`, {
  headers: { Authorization: `Bearer ${token}` },
});

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

// 기본값: /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 인스턴스를 주입할 수 있습니다.

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를 통해 사용 (권장)

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를 전달합니다.

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)

| 메서드 | 설명 | |---|---| | getProfile(uid: string) | 유저의 공개 프로필 정보 조회 → UserProfileDto | | getFollowStatus(uid: string) | 현재 로그인 사용자의 대상 유저 팔로우 상태 조회 → FollowStatusResponse | | follow(uid: string) | 특정 사용자 팔로우 → FollowUserResponse | | unfollow(uid: string) | 특정 사용자 언팔로우 → FollowUserResponse | | getFollowers(uid: string, params?: { page?: number; limit?: number }) | 특정 사용자의 팔로워 목록 조회 → PagedUserProfileListResponse | | getFollowings(uid: string, params?: { page?: number; limit?: number }) | 특정 사용자의 팔로잉 목록 조회 → PagedUserProfileListResponse | | updateProfile(uid: string, data: UpdateUserProfileRequest) | 유저의 프로필 정보 업데이트 → UserDto | | getLikedContents(uid: string, params?: Partial<ListLikedContentQuery>) | 사용자가 좋아요 누른 콘텐츠 목록 조회 → PagedContentListResponse | | getLikedStats(uid: string) | 사용자가 좋아요 누른 콘텐츠 타입별 통계 조회 → LikedStatsByTypeResponse | | block(uid: string, data?: BlockUserRequest) | 특정 사용자 차단 → BlockUserResponse | | unblock(uid: string) | 특정 사용자 차단 해제 → BlockUserResponse | | getBlockedUsers(uid: string, params?: { page?: number; limit?: number }) | 차단한 사용자 목록 조회 → PagedBlockedUserListResponse | | suspend(uid: string, data?: SuspendUserRequest) | 유저 활동 정지 (글로벌 밴) → SuspendUserResponse | | unsuspend(uid: string) | 유저 활동 정지 해제 → SuspendUserResponse |

AuthModule (client.auth)

| 메서드 | 설명 | |---|---| | login(data: LoginRequest) | 이메일/비밀번호 로그인 → AuthResponse | | loginWithFirebase(data: FirebaseExchangeRequest) | Firebase ID 토큰 교환 → AuthResponse | | register(data: RegisterRequest) | 이메일 회원가입 → AuthResponse (channelUid | channelSlug 필수, phone_number 지원) | | registerWithFirebase(data: FirebaseRegisterRequest) | 서버 사이드 Firebase 회원가입 → AuthResponse (channelUid | channelSlug 필수, phone_number 지원) | | loginAnonymous(data?: AnonymousLoginRequest) | 익명 계정 생성/로그인 → AuthResponse | | upgradeAnonymous(data: { email, password, display_name?, username?, phone_number? }) | 익명 → 정식 이메일 계정 전환 → UserDto (role: ['user']) | | me(params?: { channelUid?: string }) | 현재 사용자 프로필 조회 → UserDto | | refresh(refreshToken: string) | 토큰 갱신 → { accessToken, refreshToken } | | logout() | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |

ContentModule (client.content)

| 메서드 | 설명 | |---|---| | list(query: ListContentQuery) | 콘텐츠 목록 조회 → PagedContentListResponse | | get(uid: string, params?: { withParent?: boolean; withDetail?: boolean }) | 단일 콘텐츠 상세 조회 → 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 | | getLikers(uid: string, params?: Partial<ListLikersQuery>) | 콘텐츠에 좋아요 누른 사용자 목록 조회 → PagedResponse<UserProfileDto> | | report(uid: string, data: ReportContentRequest) | 콘텐츠 신고 → ReportContentResponse |

예시:

await client.content.list({ withParent: true, withDetail: true, page: 1, limit: 20 });
await client.content.get('cnt_123', { withParent: true, withDetail: true });

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

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

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