@ph-cms/client-sdk 0.1.9-beta.1 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,8 @@ import type {
66
66
  ContentMediaDto,
67
67
  CreateContentRequest,
68
68
  UpdateContentRequest,
69
+ CreateStampTourRequest,
70
+ UpdateStampTourRequest,
69
71
  ListContentQuery,
70
72
  PagedContentListResponse,
71
73
 
@@ -516,6 +518,32 @@ function MyComponent() {
516
518
  }
517
519
  ```
518
520
 
521
+ ### Profile Update (`useUpdateProfile`)
522
+
523
+ 사용자가 자신의 프로필을 수정할 수 있는 훅입니다. 업데이트가 성공하면 내부적으로 `refreshUser()`가 호출되어 컨텍스트와 UI가 즉각적으로 갱신됩니다.
524
+
525
+ ```tsx
526
+ import { useUpdateProfile } from '@ph-cms/client-sdk';
527
+
528
+ function ProfileEditor({ userUid }) {
529
+ const { mutateAsync: updateProfile, isPending } = useUpdateProfile();
530
+
531
+ const handleSave = async () => {
532
+ await updateProfile({
533
+ uid: userUid,
534
+ data: {
535
+ display_name: 'New Name',
536
+ avatar_url: 'https://example.com/new-avatar.png',
537
+ profile_data: { bio: 'Hello World' },
538
+ }
539
+ });
540
+ alert('프로필이 업데이트되었습니다.');
541
+ };
542
+
543
+ return <button onClick={handleSave} disabled={isPending}>저장</button>;
544
+ }
545
+ ```
546
+
519
547
  ### Standalone (Non-React) Usage
520
548
 
521
549
  React 없이 `PHCMSClient`를 직접 사용할 수 있습니다.
@@ -744,56 +772,163 @@ console.log(status?.liked); // true or false
744
772
 
745
773
  ## Stamp Tour
746
774
 
747
- 스탬프 투어 기능을 사용하여 특정 지점(Marker) 방문을 인증하고 진행 현황을 조회할 수 있습니다.
775
+ 스탬프 투어 기능을 사용하여 특정 지점(Marker) 방문을 인증하고 진행 현황을 조회하거나, 새로운 투어를 생성할 수 있습니다.
776
+
777
+ #### 스탬프 투어 생성 (인증 필수)
778
+
779
+ 여러 개의 마커(Marker)를 묶어 새로운 스탬프 투어를 생성합니다.
780
+
781
+ ```tsx
782
+ import { useCreateStampTour } from '@ph-cms/client-sdk';
783
+
784
+ function CreateTourForm() {
785
+ const { mutateAsync: createTour, isPending } = useCreateStampTour();
786
+
787
+ const handleCreate = async () => {
788
+ await createTour({
789
+ channelSlug: 'my-channel',
790
+ parentUid: 'folder-uid',
791
+ title: '서울 명소 투어',
792
+ summary: '서울의 주요 명소를 방문하세요.',
793
+ markerUids: ['marker-1-uid', 'marker-2-uid'],
794
+ isActive: true,
795
+ tags: ['서울', '여행'],
796
+ startsAt: '2026-04-01T00:00:00Z',
797
+ endsAt: '2026-06-30T23:59:59Z'
798
+ });
799
+ alert('투어가 생성되었습니다.');
800
+ };
801
+
802
+ return <button onClick={handleCreate} disabled={isPending}>투어 만들기</button>;
803
+ }
804
+ ```
805
+
806
+ #### 스탬프 투어 수정 (인증 필수)
807
+
808
+ 기존 스탬프 투어의 정보나 마커 목록을 수정합니다. (참여자가 있는 경우 마커 목록 수정은 제한될 수 있습니다.)
748
809
 
749
- ### 스탬프 획득 (인증)
810
+ ```tsx
811
+ import { useUpdateStampTour } from '@ph-cms/client-sdk';
812
+
813
+ function UpdateTourForm({ tourUid }) {
814
+ const { mutateAsync: updateTour, isPending } = useUpdateStampTour();
815
+
816
+ const handleUpdate = async () => {
817
+ await updateTour({
818
+ uid: tourUid,
819
+ data: {
820
+ title: '수정된 투어 제목',
821
+ isActive: false,
822
+ markerUids: ['marker-1-uid', 'marker-3-uid']
823
+ }
824
+ });
825
+ alert('투어가 수정되었습니다.');
826
+ };
827
+
828
+ return <button onClick={handleUpdate} disabled={isPending}>투어 수정</button>;
829
+ }
830
+ ```
831
+
832
+ #### 스탬프 획득 (인증)
750
833
 
751
834
  사용자의 현재 GPS 좌표를 전송하여 특정 투어 내의 마커 방문을 인증합니다.
752
835
 
753
- ```ts
754
- // Standalone Usage
755
- const result = await client.content.stamp(tourUid, markerUid, {
756
- lat: 37.5511,
757
- lng: 126.9882
758
- });
836
+ ```tsx
837
+ import { useStamp } from '@ph-cms/client-sdk';
838
+
839
+ function StampButton({ tourUid, markerUid }) {
840
+ const { mutateAsync: collectStamp, isPending } = useStamp();
841
+
842
+ const handleStamp = async () => {
843
+ try {
844
+ const result = await collectStamp({
845
+ tourUid,
846
+ markerUid,
847
+ data: { lat: 37.5511, lng: 126.9882 }
848
+ });
849
+
850
+ console.log(`거리: ${result.distance}m`);
851
+ if (result.isCompletion) {
852
+ alert('축하합니다! 투어를 완주하셨습니다.');
853
+ }
854
+ } catch (error) {
855
+ alert('스탬프를 획득할 수 없습니다. (너무 멀거나 이미 획득함)');
856
+ }
857
+ };
759
858
 
760
- console.log(`거리: ${result.distance}m`);
761
- if (result.isCompletion) {
762
- alert('축하합니다! 투어를 완주하셨습니다.');
859
+ return <button onClick={handleStamp} disabled={isPending}>스탬프 찍기</button>;
763
860
  }
764
861
  ```
765
862
 
766
- ### 투어 진행 현황 조회
863
+ #### 클라이언트 위치 검증 (Geo)
864
+
865
+ 스탬프를 획득하기 전 클라이언트 창에서 사용자의 현재 GPS 좌표와 마커 위치를 비교하여, 반경 내에 들어왔는지 선제적으로 검증할 수 있습니다. `checkStampAvailability` 함수와 지리 정보 훅 `useGeolocation`을 조합하여 다양한 검증 상태(`available`, `distance_far`, `not_stamp_location`, `checking_geo` 등)를 처리합니다.
866
+
867
+ ```tsx
868
+ import { useGeolocation, checkStampAvailability } from '@ph-cms/client-sdk';
869
+
870
+ function StampLocationGuard({ marker, stampStatus, isAuthenticated }) {
871
+ // 실시간 GPS 좌표 및 권한 상태 감지
872
+ const { latitude, longitude, geoError } = useGeolocation();
873
+
874
+ // 클라이언트 단에서 마커 접근 가능성 확인
875
+ const availability = checkStampAvailability({
876
+ markerUid: marker.uid,
877
+ markerLocation: marker.location, // { latitude, longitude }
878
+ stampStatus, // 내가 획득한 스탬프 정보 (StampStatusDto)
879
+ isLoggedIn: isAuthenticated, // 권한 상태 확인
880
+ latitude,
881
+ longitude,
882
+ geoError,
883
+ distanceThreshold: 40, // 스탬프 획득 가능 반경 (단위: 미터)
884
+ });
885
+
886
+ return (
887
+ <div>
888
+ <p>상태: {availability.stateMessage}</p>
889
+ <p>힌트: {availability.hintMessage}</p>
890
+ <p>거리: {availability.distance ? `${availability.distance.toFixed(1)}m` : '알 수 없음'}</p>
891
+
892
+ {availability.state === 'available' && (
893
+ <button onClick={handleCollect}>스탬프 획득하기</button>
894
+ )}
895
+ </div>
896
+ );
897
+ }
898
+ ```
899
+
900
+ #### 투어 진행 현황 조회
767
901
 
768
902
  특정 투어에 대해 내가 획득한 스탬프 목록과 완주 여부를 확인합니다.
769
903
 
770
- ```ts
771
- const status = await client.content.getStampStatus(tourUid);
904
+ ```tsx
905
+ import { useStampStatus } from '@ph-cms/client-sdk';
772
906
 
773
- console.log(`전체 마커: ${status.total_markers}`);
774
- console.log(`획득 마커: ${status.collected_markers}`);
775
- console.log(`완주 여부: ${status.is_completed}`);
907
+ function TourProgress({ tourUid }) {
908
+ const { data: status, isLoading } = useStampStatus(tourUid);
776
909
 
777
- status.stamps.forEach(s => {
778
- console.log(`마커 ${s.marker_uid} 획득 일시: ${s.collected_at}`);
779
- });
910
+ if (isLoading) return <div>Loading...</div>;
911
+
912
+ return (
913
+ <div>
914
+ <p>진행도: {status?.collected_markers} / {status?.total_markers}</p>
915
+ {status?.is_completed && <p>🏆 완주 완료!</p>}
916
+ </div>
917
+ );
918
+ }
780
919
  ```
781
920
 
782
- ### 투어 전체 통계 (관리자용)
921
+ #### 투어 전체 통계 (관리자용)
783
922
 
784
923
  투어의 총 참여자 수, 완주율 등 관리용 데이터를 조회합니다. (적절한 권한 필요)
785
924
 
786
- ```ts
787
- const stats = await client.content.getTourStats(tourUid);
788
-
789
- console.log(`총 참여자: ${stats.total_participants}`);
790
- console.log(`총 완주자: ${stats.total_completions}`);
791
- console.log(`완주율: ${stats.completion_rate * 100}%`);
792
- console.log(`평균 오차 거리: ${stats.avg_distance_meter}m`);
925
+ ```tsx
926
+ import { useTourStats } from '@ph-cms/client-sdk';
793
927
 
794
- stats.stamps_by_marker.forEach(m => {
795
- console.log(`${m.marker_title}: ${m.stamp_count} 방문`);
796
- });
928
+ function AdminStats({ tourUid }) {
929
+ const { data: stats, isLoading } = useTourStats(tourUid);
930
+ // ...
931
+ }
797
932
  ```
798
933
 
799
934
  ---
@@ -881,12 +1016,19 @@ const client = new PHCMSClient({
881
1016
  client.authProvider // AuthProvider | undefined
882
1017
  client.axiosInstance // AxiosInstance — 내부 axios 인스턴스 직접 접근
883
1018
  client.auth // AuthModule
1019
+ client.user // UserModule
884
1020
  client.content // ContentModule
885
1021
  client.channel // ChannelModule
886
1022
  client.terms // TermsModule
887
1023
  client.media // MediaModule
888
1024
  ```
889
1025
 
1026
+ ### `UserModule` (`client.user`)
1027
+
1028
+ | 메서드 | 설명 |
1029
+ |---|---|
1030
+ | `updateProfile(uid: string, data: UpdateUserProfileRequest)` | 유저의 프로필 정보 업데이트 (일반 유저는 `UpdateUserProfileRequest` 필드만 허용) → `UserDto` |
1031
+
890
1032
  ### `AuthModule` (`client.auth`)
891
1033
 
892
1034
  | 메서드 | 설명 |
@@ -898,6 +1040,23 @@ client.media // MediaModule
898
1040
  | `refresh(refreshToken: string)` | 토큰 갱신 → `{ accessToken, refreshToken }` |
899
1041
  | `logout()` | 로그아웃 (프로바이더 토큰 삭제 + 서버 세션 무효화) |
900
1042
 
1043
+ ### `ContentModule` (`client.content`)
1044
+
1045
+ | 메서드 | 설명 |
1046
+ |---|---|
1047
+ | `list(query: ListContentQuery)` | 콘텐츠 목록 조회 → `PagedContentListResponse` |
1048
+ | `get(uid: string)` | 단일 콘텐츠 상세 조회 → `ContentDto` |
1049
+ | `create(data: CreateContentRequest)` | 일반 콘텐츠 생성 → `ContentDto` |
1050
+ | `update(uid: string, data: UpdateContentRequest)` | 콘텐츠 수정 → `ContentDto` |
1051
+ | `delete(uid: string)` | 콘텐츠 삭제 |
1052
+ | `createStampTour(data: CreateStampTourRequest)` | **스탬프 투어 생성** → `ContentDto` |
1053
+ | `updateStampTour(uid: string, data: UpdateStampTourRequest)` | **스탬프 투어 수정** → `ContentDto` |
1054
+ | `stamp(tourUid: string, markerUid: string, data: CollectStampRequest)` | 스탬프 획득 인증 → `{ isCompletion, distance }` |
1055
+ | `getStampStatus(tourUid: string)` | 내 스탬프 획득 현황 조회 → `StampStatusDto` |
1056
+ | `getTourStats(tourUid: string)` | 투어 전체 통계 조회 → `TourStatsDto` |
1057
+ | `toggleLike(uid: string)` | 좋아요 토글 → `ToggleLikeResponse` |
1058
+ | `getLikeStatus(uid: string)` | 내 좋아요 여부 확인 → `LikeStatusResponse` |
1059
+
901
1060
  ### JWT Utilities
902
1061
 
903
1062
  클라이언트에서 토큰 상태를 확인할 수 있는 유틸리티입니다 (서명 검증은 하지 않음).
package/dist/client.d.ts CHANGED
@@ -5,6 +5,7 @@ import { ChannelModule } from './modules/channel';
5
5
  import { ContentModule } from './modules/content';
6
6
  import { MediaModule } from './modules/media';
7
7
  import { TermsModule } from './modules/terms';
8
+ import { UserModule } from './modules/user';
8
9
  export interface PHCMSClientConfig {
9
10
  baseURL: string;
10
11
  apiPrefix?: string;
@@ -19,6 +20,7 @@ export declare class PHCMSClient {
19
20
  readonly channel: ChannelModule;
20
21
  readonly terms: TermsModule;
21
22
  readonly media: MediaModule;
23
+ readonly user: UserModule;
22
24
  /**
23
25
  * Whether a token refresh is currently in flight (used by the 401
24
26
  * interceptor to de-duplicate concurrent refresh attempts).
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@ const channel_1 = require("./modules/channel");
11
11
  const content_1 = require("./modules/content");
12
12
  const media_1 = require("./modules/media");
13
13
  const terms_1 = require("./modules/terms");
14
+ const user_1 = require("./modules/user");
14
15
  class PHCMSClient {
15
16
  /** Exposes the auth provider so UI layers can check token state synchronously. */
16
17
  get authProvider() {
@@ -44,6 +45,7 @@ class PHCMSClient {
44
45
  this.channel = new channel_1.ChannelModule(this.axiosInstance);
45
46
  this.terms = new terms_1.TermsModule(this.axiosInstance);
46
47
  this.media = new media_1.MediaModule(this.axiosInstance);
48
+ this.user = new user_1.UserModule(this.axiosInstance);
47
49
  // Wire the refresh function into the auth provider so it can
48
50
  // proactively refresh tokens inside `getToken()` without needing
49
51
  // a direct reference to the AuthModule / axios instance.
@@ -9,8 +9,10 @@ export declare const contentKeys: {
9
9
  type?: string | undefined;
10
10
  tags?: string[] | undefined;
11
11
  channelUid?: string | undefined;
12
+ channelSlug?: string | undefined;
12
13
  parentUid?: string | undefined;
13
14
  authorUid?: string | undefined;
15
+ uids?: string[] | undefined;
14
16
  rootUid?: string | undefined;
15
17
  keyword?: string | undefined;
16
18
  orderBy?: "created_at" | "updated_at" | "published_at" | "title" | "view_count" | "like_count" | undefined;
@@ -0,0 +1,109 @@
1
+ import { CollectStampRequest, StampStatusDto, UpdateStampTourRequest, ContentDto } from '@ph-cms/api-contract';
2
+ export declare const stampTourKeys: {
3
+ all: readonly ["stamp-tours"];
4
+ status: (tourUid: string) => readonly ["stamp-tours", "status", string];
5
+ stats: (tourUid: string) => readonly ["stamp-tours", "stats", string];
6
+ };
7
+ export declare const useCreateStampTour: () => import("@tanstack/react-query").UseMutationResult<ContentDto, Error, {
8
+ title: string;
9
+ parentUid: string;
10
+ markerUids: string[];
11
+ isActive: boolean;
12
+ status?: string | undefined;
13
+ geometry?: {
14
+ type: "Point" | "LineString" | "Polygon" | "MultiPoint" | "MultiLineString" | "MultiPolygon" | "GeometryCollection" | "Feature" | "FeatureCollection";
15
+ coordinates?: any[] | undefined;
16
+ geometries?: any[] | undefined;
17
+ features?: any[] | undefined;
18
+ properties?: Record<string, any> | undefined;
19
+ geometry?: any;
20
+ } | undefined;
21
+ image?: string | null | undefined;
22
+ summary?: string | null | undefined;
23
+ tags?: string[] | undefined;
24
+ channelUid?: string | undefined;
25
+ channelSlug?: string | undefined;
26
+ startsAt?: string | null | undefined;
27
+ endsAt?: string | null | undefined;
28
+ }, unknown>;
29
+ export declare const useUpdateStampTour: () => import("@tanstack/react-query").UseMutationResult<ContentDto, Error, {
30
+ uid: string;
31
+ data: UpdateStampTourRequest;
32
+ }, unknown>;
33
+ export declare const useStamp: () => import("@tanstack/react-query").UseMutationResult<{
34
+ isCompletion: boolean;
35
+ distance: number;
36
+ }, Error, {
37
+ tourUid: string;
38
+ markerUid: string;
39
+ data?: CollectStampRequest;
40
+ }, unknown>;
41
+ export declare const useStampStatus: (tourUid: string, enabled?: boolean) => import("@tanstack/react-query").UseQueryResult<{
42
+ total_markers: number;
43
+ collected_markers: number;
44
+ is_completed: boolean;
45
+ stamps: {
46
+ marker_uid: string;
47
+ collected_at: string;
48
+ }[];
49
+ }, Error>;
50
+ export declare const useTourStats: (tourUid: string, enabled?: boolean) => import("@tanstack/react-query").UseQueryResult<{
51
+ total_markers: number;
52
+ tour_uid: string;
53
+ tour_title: string;
54
+ total_participants: number;
55
+ total_completions: number;
56
+ completion_rate: number;
57
+ avg_distance_meter: number;
58
+ stamps_by_marker: {
59
+ marker_uid: string;
60
+ marker_title: string;
61
+ stamp_count: number;
62
+ }[];
63
+ }, Error>;
64
+ export interface StampAvailability {
65
+ currentLocation?: {
66
+ latitude: number;
67
+ longitude: number;
68
+ };
69
+ stampLocation?: {
70
+ latitude: number;
71
+ longitude: number;
72
+ };
73
+ state: "available" | "not_logged_in" | "invalid_location" | "distance_far" | "checking_geo" | "geo_error" | "already_acquired" | "not_stamp_location";
74
+ stateMessage: string;
75
+ hintMessage: string;
76
+ distance: number | null;
77
+ }
78
+ export declare const calculateDistance: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
79
+ export declare const getStampAvailability: ({ state, currentLocation, stampLocation, distance, }: {
80
+ state: StampAvailability["state"];
81
+ currentLocation?: {
82
+ latitude: number;
83
+ longitude: number;
84
+ };
85
+ stampLocation?: {
86
+ latitude: number;
87
+ longitude: number;
88
+ };
89
+ distance?: number;
90
+ }) => StampAvailability;
91
+ export interface CheckStampAvailabilityParams {
92
+ markerUid: string;
93
+ markerLocation?: {
94
+ latitude?: number | null;
95
+ longitude?: number | null;
96
+ } | null;
97
+ stampStatus?: StampStatusDto | null;
98
+ isLoggedIn: boolean;
99
+ latitude: number | null;
100
+ longitude: number | null;
101
+ geoError: any;
102
+ distanceThreshold?: number;
103
+ }
104
+ export declare const checkStampAvailability: ({ markerUid, markerLocation, stampStatus, isLoggedIn, latitude, longitude, geoError, distanceThreshold, }: CheckStampAvailabilityParams) => StampAvailability;
105
+ export declare const useGeolocation: (enabled?: boolean) => {
106
+ latitude: number | null;
107
+ longitude: number | null;
108
+ geoError: string | null;
109
+ };
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useGeolocation = exports.checkStampAvailability = exports.getStampAvailability = exports.calculateDistance = exports.useTourStats = exports.useStampStatus = exports.useStamp = exports.useUpdateStampTour = exports.useCreateStampTour = exports.stampTourKeys = void 0;
4
+ const react_query_1 = require("@tanstack/react-query");
5
+ const context_1 = require("../context");
6
+ const useContent_1 = require("./useContent");
7
+ const react_1 = require("react");
8
+ exports.stampTourKeys = {
9
+ all: ['stamp-tours'],
10
+ status: (tourUid) => [...exports.stampTourKeys.all, 'status', tourUid],
11
+ stats: (tourUid) => [...exports.stampTourKeys.all, 'stats', tourUid],
12
+ };
13
+ const useCreateStampTour = () => {
14
+ const client = (0, context_1.usePHCMS)();
15
+ const queryClient = (0, react_query_1.useQueryClient)();
16
+ return (0, react_query_1.useMutation)({
17
+ mutationFn: (data) => client.content.createStampTour(data),
18
+ onSuccess: () => {
19
+ // Invalidate content lists since a new tour is a content item
20
+ queryClient.invalidateQueries({ queryKey: useContent_1.contentKeys.lists() });
21
+ },
22
+ });
23
+ };
24
+ exports.useCreateStampTour = useCreateStampTour;
25
+ const useUpdateStampTour = () => {
26
+ const client = (0, context_1.usePHCMS)();
27
+ const queryClient = (0, react_query_1.useQueryClient)();
28
+ return (0, react_query_1.useMutation)({
29
+ mutationFn: ({ uid, data }) => client.content.updateStampTour(uid, data),
30
+ onSuccess: (data, variables) => {
31
+ queryClient.invalidateQueries({ queryKey: useContent_1.contentKeys.detail(variables.uid) });
32
+ queryClient.invalidateQueries({ queryKey: useContent_1.contentKeys.lists() });
33
+ },
34
+ });
35
+ };
36
+ exports.useUpdateStampTour = useUpdateStampTour;
37
+ const useStamp = () => {
38
+ const client = (0, context_1.usePHCMS)();
39
+ const queryClient = (0, react_query_1.useQueryClient)();
40
+ return (0, react_query_1.useMutation)({
41
+ mutationFn: async ({ tourUid, markerUid, data }) => {
42
+ let requestData = data;
43
+ if (!requestData) {
44
+ try {
45
+ const position = await new Promise((resolve, reject) => {
46
+ navigator.geolocation.getCurrentPosition(resolve, reject, {
47
+ enableHighAccuracy: true,
48
+ timeout: 5000,
49
+ maximumAge: 0
50
+ });
51
+ });
52
+ requestData = {
53
+ lat: position.coords.latitude,
54
+ lng: position.coords.longitude,
55
+ };
56
+ }
57
+ catch (error) {
58
+ throw new Error('위치 정보를 가져올 수 없습니다. 위치 권한을 확인해주세요.');
59
+ }
60
+ }
61
+ return client.content.stamp(tourUid, markerUid, requestData);
62
+ },
63
+ onSuccess: (data, variables) => {
64
+ queryClient.invalidateQueries({ queryKey: exports.stampTourKeys.status(variables.tourUid) });
65
+ },
66
+ });
67
+ };
68
+ exports.useStamp = useStamp;
69
+ const useStampStatus = (tourUid, enabled = true) => {
70
+ const client = (0, context_1.usePHCMS)();
71
+ return (0, react_query_1.useQuery)({
72
+ queryKey: exports.stampTourKeys.status(tourUid),
73
+ queryFn: () => client.content.getStampStatus(tourUid),
74
+ enabled: !!tourUid && enabled,
75
+ });
76
+ };
77
+ exports.useStampStatus = useStampStatus;
78
+ const useTourStats = (tourUid, enabled = true) => {
79
+ const client = (0, context_1.usePHCMS)();
80
+ return (0, react_query_1.useQuery)({
81
+ queryKey: exports.stampTourKeys.stats(tourUid),
82
+ queryFn: () => client.content.getTourStats(tourUid),
83
+ enabled: !!tourUid && enabled,
84
+ });
85
+ };
86
+ exports.useTourStats = useTourStats;
87
+ const calculateDistance = (lat1, lon1, lat2, lon2) => {
88
+ const R = 6371000;
89
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
90
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
91
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
92
+ Math.cos((lat1 * Math.PI) / 180) *
93
+ Math.cos((lat2 * Math.PI) / 180) *
94
+ Math.sin(dLon / 2) *
95
+ Math.sin(dLon / 2);
96
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
97
+ return R * c;
98
+ };
99
+ exports.calculateDistance = calculateDistance;
100
+ const getStampAvailability = ({ state, currentLocation, stampLocation, distance, }) => {
101
+ const stateMessages = {
102
+ available: "스탬프 획득 가능",
103
+ not_logged_in: "스탬프 획득 불가 (로그인 필요)",
104
+ invalid_location: "장소 정보를 찾을 수 없습니다.",
105
+ distance_far: `스탬프 획득 불가 (${distance?.toFixed(1) || 0}m)`,
106
+ checking_geo: "현재 위치를 확인하고 있습니다.",
107
+ geo_error: "스탬프 획득 불가 (위치 권한 필요)",
108
+ already_acquired: "스탬프 획득 완료",
109
+ not_stamp_location: "스탬프 획득 불가 (스탬프 위치 아님)",
110
+ };
111
+ const hintMessages = {
112
+ available: "이 버튼을 눌러 스탬프를 획득해주세요.",
113
+ not_logged_in: "로그인 후 스탬프를 획득할 수 있습니다.",
114
+ invalid_location: "유효한 장소에서 시도해주세요.",
115
+ distance_far: "이 버튼을 눌러 위치를 업데이트 하세요.",
116
+ checking_geo: "잠시만 기다려주세요.",
117
+ geo_error: "위치 권한을 허용해주세요.",
118
+ already_acquired: "획득한 스탬프 목록을 확인해보세요.",
119
+ not_stamp_location: "스탬프 위치에서 시도해주세요.",
120
+ };
121
+ return {
122
+ currentLocation,
123
+ stampLocation,
124
+ state,
125
+ stateMessage: stateMessages[state],
126
+ hintMessage: hintMessages[state],
127
+ distance: distance ?? null,
128
+ };
129
+ };
130
+ exports.getStampAvailability = getStampAvailability;
131
+ const checkStampAvailability = ({ markerUid, markerLocation, stampStatus, isLoggedIn, latitude, longitude, geoError, distanceThreshold = 40, }) => {
132
+ if (!markerLocation) {
133
+ return (0, exports.getStampAvailability)({ state: "not_stamp_location" });
134
+ }
135
+ if (!markerLocation.latitude || !markerLocation.longitude) {
136
+ return (0, exports.getStampAvailability)({ state: "invalid_location" });
137
+ }
138
+ if (geoError) {
139
+ return (0, exports.getStampAvailability)({ state: "geo_error" });
140
+ }
141
+ if (latitude === null || longitude === null) {
142
+ return (0, exports.getStampAvailability)({ state: "checking_geo" });
143
+ }
144
+ if (!isLoggedIn) {
145
+ return (0, exports.getStampAvailability)({ state: "not_logged_in" });
146
+ }
147
+ const acquiredUids = stampStatus?.stamps?.map((s) => s.marker_uid) || [];
148
+ if (acquiredUids.includes(markerUid)) {
149
+ return (0, exports.getStampAvailability)({ state: "already_acquired" });
150
+ }
151
+ const currentLocation = { latitude, longitude };
152
+ const stampLocationCoords = {
153
+ latitude: markerLocation.latitude,
154
+ longitude: markerLocation.longitude,
155
+ };
156
+ const distance = (0, exports.calculateDistance)(latitude, longitude, markerLocation.latitude, markerLocation.longitude);
157
+ if (distance > distanceThreshold) {
158
+ return (0, exports.getStampAvailability)({
159
+ state: "distance_far",
160
+ currentLocation,
161
+ stampLocation: stampLocationCoords,
162
+ distance,
163
+ });
164
+ }
165
+ return (0, exports.getStampAvailability)({
166
+ state: "available",
167
+ currentLocation,
168
+ stampLocation: stampLocationCoords,
169
+ distance,
170
+ });
171
+ };
172
+ exports.checkStampAvailability = checkStampAvailability;
173
+ const useGeolocation = (enabled = true) => {
174
+ const [latitude, setLatitude] = (0, react_1.useState)(null);
175
+ const [longitude, setLongitude] = (0, react_1.useState)(null);
176
+ const [geoError, setGeoError] = (0, react_1.useState)(null);
177
+ (0, react_1.useEffect)(() => {
178
+ if (!enabled || typeof navigator === 'undefined' || !navigator.geolocation) {
179
+ if (enabled && typeof navigator !== 'undefined') {
180
+ setGeoError('Geolocation is not supported by your browser');
181
+ }
182
+ return;
183
+ }
184
+ const watchId = navigator.geolocation.watchPosition((position) => {
185
+ setLatitude(position.coords.latitude);
186
+ setLongitude(position.coords.longitude);
187
+ setGeoError(null);
188
+ }, (error) => {
189
+ setGeoError(error.message);
190
+ }, {
191
+ enableHighAccuracy: true,
192
+ maximumAge: 10000,
193
+ timeout: 5000,
194
+ });
195
+ return () => {
196
+ navigator.geolocation.clearWatch(watchId);
197
+ };
198
+ }, [enabled]);
199
+ return { latitude, longitude, geoError };
200
+ };
201
+ exports.useGeolocation = useGeolocation;
@@ -5,8 +5,8 @@ export declare const termsKeys: {
5
5
  list: (params?: ListTermsQuery) => readonly ["terms", "list", {
6
6
  code?: string | undefined;
7
7
  limit?: number | undefined;
8
- offset?: number | undefined;
9
8
  isActive?: boolean | undefined;
9
+ offset?: number | undefined;
10
10
  } | undefined];
11
11
  };
12
12
  export declare const useTermsList: (params?: ListTermsQuery) => import("@tanstack/react-query").UseQueryResult<{
@@ -14,9 +14,9 @@ export declare const useTermsList: (params?: ListTermsQuery) => import("@tanstac
14
14
  code: string;
15
15
  title: string;
16
16
  content: string;
17
+ isActive: boolean;
17
18
  id: number;
18
19
  version: string;
19
- isActive: boolean;
20
20
  createdAt: string;
21
21
  }[];
22
22
  total: number;
@@ -0,0 +1,26 @@
1
+ import { UpdateUserProfileRequest } from '@ph-cms/api-contract';
2
+ /**
3
+ * Hook to update a user's profile.
4
+ * Typically used by a user to update their own profile.
5
+ */
6
+ export declare const useUpdateProfile: () => import("@tanstack/react-query").UseMutationResult<{
7
+ uid: string;
8
+ email: string;
9
+ username: string | null;
10
+ display_name: string;
11
+ avatar_url: string | null;
12
+ phone_number: string | null;
13
+ email_verified_at: string | null;
14
+ phone_verified_at: string | null;
15
+ locale: string;
16
+ timezone: string;
17
+ status: string;
18
+ role: string[];
19
+ profile_data: Record<string, any>;
20
+ last_login_at: string | null;
21
+ created_at: string;
22
+ updated_at: string;
23
+ }, Error, {
24
+ uid: string;
25
+ data: UpdateUserProfileRequest | Record<string, any>;
26
+ }, unknown>;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useUpdateProfile = void 0;
4
+ const react_query_1 = require("@tanstack/react-query");
5
+ const context_1 = require("../context");
6
+ /**
7
+ * Hook to update a user's profile.
8
+ * Typically used by a user to update their own profile.
9
+ */
10
+ const useUpdateProfile = () => {
11
+ const client = (0, context_1.usePHCMS)();
12
+ const { refreshUser } = (0, context_1.usePHCMSContext)();
13
+ return (0, react_query_1.useMutation)({
14
+ mutationFn: (params) => client.user.updateProfile(params.uid, params.data),
15
+ onSuccess: async () => {
16
+ // Refresh user in context after a successful update so the UI gets the new data
17
+ await refreshUser();
18
+ },
19
+ });
20
+ };
21
+ exports.useUpdateProfile = useUpdateProfile;
package/dist/index.d.ts CHANGED
@@ -10,11 +10,14 @@ export * from './modules/channel';
10
10
  export * from './modules/content';
11
11
  export * from './modules/media';
12
12
  export * from './modules/terms';
13
+ export * from './modules/user';
13
14
  export * from './context';
14
15
  export * from './hooks/useAuth';
15
16
  export * from './hooks/useChannel';
16
17
  export * from './hooks/useContent';
18
+ export * from './hooks/useStampTour';
17
19
  export * from './hooks/useFirebaseAuthSync';
18
20
  export * from './hooks/useMedia';
19
21
  export * from './hooks/useTerms';
22
+ export * from './hooks/useUser';
20
23
  export * from './types';
package/dist/index.js CHANGED
@@ -26,11 +26,14 @@ __exportStar(require("./modules/channel"), exports);
26
26
  __exportStar(require("./modules/content"), exports);
27
27
  __exportStar(require("./modules/media"), exports);
28
28
  __exportStar(require("./modules/terms"), exports);
29
+ __exportStar(require("./modules/user"), exports);
29
30
  __exportStar(require("./context"), exports);
30
31
  __exportStar(require("./hooks/useAuth"), exports);
31
32
  __exportStar(require("./hooks/useChannel"), exports);
32
33
  __exportStar(require("./hooks/useContent"), exports);
34
+ __exportStar(require("./hooks/useStampTour"), exports);
33
35
  __exportStar(require("./hooks/useFirebaseAuthSync"), exports);
34
36
  __exportStar(require("./hooks/useMedia"), exports);
35
37
  __exportStar(require("./hooks/useTerms"), exports);
38
+ __exportStar(require("./hooks/useUser"), exports);
36
39
  __exportStar(require("./types"), exports);
@@ -1,5 +1,5 @@
1
+ import { BoundsQuery, CollectStampRequest, ContentDto, CreateContentRequest, CreateStampTourRequest, LikeStatusResponse, ListContentQuery, PagedContentListResponse, StampStatusDto, ToggleLikeResponse, TourStatsDto, UpdateContentRequest, UpdateStampTourRequest } from "@ph-cms/api-contract";
1
2
  import { AxiosInstance } from "axios";
2
- import { CreateContentRequest, UpdateContentRequest, ListContentQuery, ContentDto, PagedContentListResponse, BoundsQuery, CollectStampRequest, StampStatusDto, TourStatsDto, ToggleLikeResponse, LikeStatusResponse } from "@ph-cms/api-contract";
3
3
  export declare class ContentModule {
4
4
  private client;
5
5
  constructor(client: AxiosInstance);
@@ -9,6 +9,8 @@ export declare class ContentModule {
9
9
  create(data: CreateContentRequest): Promise<ContentDto>;
10
10
  update(uid: string, data: UpdateContentRequest): Promise<ContentDto>;
11
11
  delete(uid: string): Promise<void>;
12
+ createStampTour(data: CreateStampTourRequest): Promise<ContentDto>;
13
+ updateStampTour(uid: string, data: UpdateStampTourRequest): Promise<ContentDto>;
12
14
  stamp(tourUid: string, markerUid: string, data: CollectStampRequest): Promise<{
13
15
  isCompletion: boolean;
14
16
  distance: number;
@@ -47,6 +47,22 @@ class ContentModule {
47
47
  throw new errors_1.ValidationError("UID is required", []);
48
48
  return this.client.delete(`/api/contents/${uid}`);
49
49
  }
50
+ async createStampTour(data) {
51
+ const validation = api_contract_1.CreateStampTourSchema.safeParse(data);
52
+ if (!validation.success) {
53
+ throw new errors_1.ValidationError("Invalid create stamp tour data", validation.error.errors);
54
+ }
55
+ return this.client.post('/api/contents/stamp-tours', data);
56
+ }
57
+ async updateStampTour(uid, data) {
58
+ if (!uid)
59
+ throw new errors_1.ValidationError("UID is required", []);
60
+ const validation = api_contract_1.UpdateStampTourSchema.safeParse(data);
61
+ if (!validation.success) {
62
+ throw new errors_1.ValidationError("Invalid update stamp tour data", validation.error.errors);
63
+ }
64
+ return this.client.put(`/api/contents/stamp-tours/${uid}`, data);
65
+ }
50
66
  async stamp(tourUid, markerUid, data) {
51
67
  if (!tourUid || !markerUid)
52
68
  throw new errors_1.ValidationError("Both tourUid and markerUid are required", []);
@@ -0,0 +1,17 @@
1
+ import { UpdateUserProfileRequest, UserDto } from "@ph-cms/api-contract";
2
+ import { AxiosInstance } from "axios";
3
+ export declare class UserModule {
4
+ private client;
5
+ constructor(client: AxiosInstance);
6
+ /**
7
+ * Updates a user's profile.
8
+ *
9
+ * Normal users can only update specific fields defined in `UpdateUserProfileRequest`
10
+ * (e.g., `display_name`, `avatar_url`, `profile_data`).
11
+ * Admin users can pass any updatable fields.
12
+ *
13
+ * @param uid - The UID of the user to update.
14
+ * @param data - The update payload.
15
+ */
16
+ updateProfile(uid: string, data: UpdateUserProfileRequest | Record<string, any>): Promise<UserDto>;
17
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UserModule = void 0;
4
+ class UserModule {
5
+ constructor(client) {
6
+ this.client = client;
7
+ }
8
+ /**
9
+ * Updates a user's profile.
10
+ *
11
+ * Normal users can only update specific fields defined in `UpdateUserProfileRequest`
12
+ * (e.g., `display_name`, `avatar_url`, `profile_data`).
13
+ * Admin users can pass any updatable fields.
14
+ *
15
+ * @param uid - The UID of the user to update.
16
+ * @param data - The update payload.
17
+ */
18
+ async updateProfile(uid, data) {
19
+ return this.client.patch(`/api/users/${uid}`, data);
20
+ }
21
+ }
22
+ exports.UserModule = UserModule;
package/dist/types.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { JwtPayload } from './auth/jwt-utils';
3
3
  export type { PHCMSClientConfig } from './client';
4
4
  export type { AuthStatus, PHCMSContextType, PHCMSProviderProps } from './context';
5
5
  export type { FirebaseAuthSyncProps, UseFirebaseAuthSyncOptions, UseFirebaseAuthSyncReturn } from './hooks/useFirebaseAuthSync';
6
+ export type { StampAvailability, CheckStampAvailabilityParams } from './hooks/useStampTour';
6
7
  export type { AuthResponse, FirebaseExchangeRequest, LoginRequest, RefreshTokenRequest, RegisterRequest } from '@ph-cms/api-contract';
7
8
  export type { UserDto } from '@ph-cms/api-contract';
8
9
  export type { ChannelDto, CheckHierarchyQuery, CreateChannelDto, ListChannelQuery, PagedChannelListResponse } from '@ph-cms/api-contract';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ph-cms/client-sdk",
3
- "version": "0.1.9-beta.1",
3
+ "version": "0.1.10",
4
4
  "description": "Unified PH-CMS Client SDK (React + Core)",
5
5
  "keywords": [],
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "LICENSE"
22
22
  ],
23
23
  "dependencies": {
24
- "@ph-cms/api-contract": "0.1.2-beta.1",
24
+ "@ph-cms/api-contract": "file:../api-contract",
25
25
  "@tanstack/react-query": "^5.0.0",
26
26
  "axios": "^1.6.0",
27
27
  "zod": "^3.22.4"