@ph-cms/client-sdk
v0.1.11
Published
Unified PH-CMS Client SDK (React + Core)
Readme
@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
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,
RefreshTokenRequest,
FirebaseExchangeRequest,
AuthResponse,
// User
UserDto,
// Channel
ChannelDto,
CreateChannelDto,
ListChannelQuery,
CheckHierarchyQuery,
PagedChannelListResponse,
// Content
ContentDto,
ContentMediaDto,
CreateContentRequest,
UpdateContentRequest,
CreateStampTourRequest,
UpdateStampTourRequest,
ListContentQuery,
PagedContentListResponse,
// Hierarchy / Policy
HierarchySetDto,
PermissionPolicySetDto,
// Terms
TermDto,
ListTermsQuery,
PagedTermListResponse,
// Geo
GeoJSON,
BoundsQuery,
// 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: nullToken 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에 저장합니다.
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() 동작:
- PH-CMS access token이 유효하면 → 그대로 반환
- 만료 임박이면 →
tryRefresh()시도 → 성공하면 새 토큰 반환 - PH-CMS 토큰이 없거나 갱신 실패 → Firebase ID 토큰으로 fallback
AuthProvider Interface
커스텀 인증 Provider를 구현하려면 아래 인터페이스를 따릅니다.
BaseAuthProvider를 상속하면 대부분의 메서드가 이미 구현되어 있으므로, type과 getToken()만 구현하면 됩니다.
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 />;
}
}
isAuthenticated와isLoading은 하위 호환을 위해 유지되지만, 내부적으로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: RegisterRequest) => Promise<AuthResponse>
logout, // () => Promise<void>
// 뮤테이션 상태 (isPending, error 등)
loginStatus,
loginWithFirebaseStatus,
registerStatus,
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 로그인은 두 단계로 이루어집니다:
- Firebase SDK로 인증하여 ID 토큰 획득
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>
);
}회원가입
function RegisterForm() {
const { register, registerStatus } = useAuth();
const handleRegister = async (formData: {
email: string;
password: string;
display_name: string;
username?: string;
}) => {
await register(formData);
// 성공 → 자동으로 me() 호출 → 즉시 인증 상태로 전환
};
// ...
}로그아웃
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 갱신
};
}Profile Update (useUpdateProfile)
사용자가 자신의 프로필을 수정할 수 있는 훅입니다. 업데이트가 성공하면 내부적으로 refreshUser()가 호출되어 컨텍스트와 UI가 즉각적으로 갱신됩니다.
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>;
}Standalone (Non-React) Usage
React 없이 PHCMSClient를 직접 사용할 수 있습니다.
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,
});
// 로그인 — provider에 토큰이 자동 저장됨
const authResponse = await client.auth.login({
email: '[email protected]',
password: 'password',
});
// 프로필 조회
const me = await client.auth.me();
console.log(me.email);
// 토큰 갱신 (수동)
// 일반적으로 SDK가 자동으로 처리하지만, 필요 시 직접 호출 가능
const refreshToken = authProvider.getRefreshToken();
if (refreshToken) {
const newTokens = await client.auth.refresh(refreshToken);
authProvider.setTokens(newTokens.accessToken, newTokens.refreshToken);
}
// 로그아웃
await client.auth.logout();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()
│ → client.auth.loginWithFirebase({ idToken })
│ → provider.setTokens(...)
│ → refreshUser() ← me() 호출하여 프로필 로드
│
└─ fbUser null (로그아웃) + PH-CMS 인증 상태
→ client.auth.logout()
→ refreshUser() ← 상태 초기화<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
import { useContentList, useContentDetail } from '@ph-cms/client-sdk';
function ContentList() {
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>
);
}Channel
import { useChannelList } from '@ph-cms/client-sdk';
function ChannelList() {
const { data, isLoading } = useChannelList({ limit: 20 });
// ...
}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 falseStamp 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>;
}스탬프 투어 수정 (인증 필수)
기존 스탬프 투어의 정보나 마커 목록을 수정합니다. (참여자가 있는 경우 마커 목록 수정은 제한될 수 있습니다.)
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('잘못된 인증 정보입니다.');
}
}
}API Reference
PHCMSClient
const client = new PHCMSClient({
baseURL: string; // 서버 URL (필수)
apiPrefix?: string; // API 경로 접두사 (기본값: '/api')
auth?: AuthProvider; // 인증 프로바이더
timeout?: number; // 요청 타임아웃 ms (기본값: 10000)
});
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 // MediaModuleUserModule (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() | 현재 사용자 프로필 조회 → 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 |
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) | nullLicense
MIT
