@sayren/storefront-sdk 0.1.0
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/dist/index.d.mts +1383 -0
- package/dist/index.mjs +880 -0
- package/package.json +43 -0
- package/src/client.ts +349 -0
- package/src/http.ts +178 -0
- package/src/index.ts +22 -0
- package/src/schemas/auth.ts +41 -0
- package/src/schemas/cart.ts +47 -0
- package/src/schemas/catalog.ts +101 -0
- package/src/schemas/checkout.ts +183 -0
- package/src/schemas/claim.ts +51 -0
- package/src/schemas/common.ts +46 -0
- package/src/schemas/customer-inquiry.ts +39 -0
- package/src/schemas/inquiry.ts +25 -0
- package/src/schemas/member.ts +50 -0
- package/src/schemas/order.ts +76 -0
- package/src/schemas/review.ts +65 -0
- package/src/session.ts +212 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sayren/storefront-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./dist/index.mjs",
|
|
5
|
+
"types": "./dist/index.d.mts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"development": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"default": "./dist/index.mjs"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"zod": "^4.6.5"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@biomejs/biome": "^2.5.14",
|
|
18
|
+
"tsdown": "^0.23.0",
|
|
19
|
+
"typescript": "^6.0.3",
|
|
20
|
+
"vitest": "^5.0.1"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"license": "UNLICENSED",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/cochoio/shop.24.git"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"description": "sayren 스토어프론트 API SDK — zod 스키마 + fetch 클라이언트",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"dev": "tsdown --watch",
|
|
39
|
+
"lint": "biome check --write . && tsc --noEmit",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "vitest run"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createHttp, path, type Query, resolveToken, type TokenSource } from "./http";
|
|
3
|
+
import type { LoginRequest, SignupRequest } from "./schemas/auth";
|
|
4
|
+
import { loginRequestSchema, signupRequestSchema, tokenPairSchema } from "./schemas/auth";
|
|
5
|
+
import {
|
|
6
|
+
type AddCartItemRequest,
|
|
7
|
+
addCartItemRequestSchema,
|
|
8
|
+
cartSchema,
|
|
9
|
+
type UpdateCartItemRequest,
|
|
10
|
+
updateCartItemRequestSchema,
|
|
11
|
+
} from "./schemas/cart";
|
|
12
|
+
import {
|
|
13
|
+
categoryNodeSchema,
|
|
14
|
+
type ProductSearchParams,
|
|
15
|
+
productCardSchema,
|
|
16
|
+
productDetailSchema,
|
|
17
|
+
productFacetsSchema,
|
|
18
|
+
} from "./schemas/catalog";
|
|
19
|
+
import {
|
|
20
|
+
type CreateCheckoutRequest,
|
|
21
|
+
checkoutSessionSchema,
|
|
22
|
+
confirmPaymentRequestSchema,
|
|
23
|
+
confirmPaymentResultSchema,
|
|
24
|
+
createCheckoutRequestSchema,
|
|
25
|
+
paymentParamsSchema,
|
|
26
|
+
paymentStatusSchema,
|
|
27
|
+
type RequestPaymentRequest,
|
|
28
|
+
requestPaymentRequestSchema,
|
|
29
|
+
} from "./schemas/checkout";
|
|
30
|
+
import {
|
|
31
|
+
type CreateClaimRequest,
|
|
32
|
+
createClaimRequestSchema,
|
|
33
|
+
createClaimResultSchema,
|
|
34
|
+
myClaimSchema,
|
|
35
|
+
} from "./schemas/claim";
|
|
36
|
+
import {
|
|
37
|
+
type ClaimStatus,
|
|
38
|
+
type OrderItemStatus,
|
|
39
|
+
type PageParams,
|
|
40
|
+
pageSchema,
|
|
41
|
+
} from "./schemas/common";
|
|
42
|
+
import {
|
|
43
|
+
type CreateCustomerInquiryRequest,
|
|
44
|
+
type CustomerInquiryCategory,
|
|
45
|
+
createCustomerInquiryRequestSchema,
|
|
46
|
+
customerInquirySchema,
|
|
47
|
+
} from "./schemas/customer-inquiry";
|
|
48
|
+
import { type CreateInquiryRequest, publicInquirySchema } from "./schemas/inquiry";
|
|
49
|
+
import {
|
|
50
|
+
type MemberAddressRequest,
|
|
51
|
+
memberAddressRequestSchema,
|
|
52
|
+
memberAddressSchema,
|
|
53
|
+
memberSchema,
|
|
54
|
+
type UpdateProfileRequest,
|
|
55
|
+
updateProfileRequestSchema,
|
|
56
|
+
} from "./schemas/member";
|
|
57
|
+
import { deliveryTrackingSchema, myOrderSchema } from "./schemas/order";
|
|
58
|
+
import {
|
|
59
|
+
type CreateReviewRequest,
|
|
60
|
+
createReviewRequestSchema,
|
|
61
|
+
createReviewResultSchema,
|
|
62
|
+
publicReviewSchema,
|
|
63
|
+
reviewSummarySchema,
|
|
64
|
+
type UpdateReviewRequest,
|
|
65
|
+
updateReviewRequestSchema,
|
|
66
|
+
writableReviewSchema,
|
|
67
|
+
} from "./schemas/review";
|
|
68
|
+
import type { StorefrontSession } from "./session";
|
|
69
|
+
|
|
70
|
+
export interface StorefrontClientOptions {
|
|
71
|
+
/** 예: https://demo.store.example.com/api/v1 */
|
|
72
|
+
baseUrl: string;
|
|
73
|
+
/**
|
|
74
|
+
* 테넌트 스토어 코드 — `X-Store-Code` 헤더로 전송한다. 공유 API 호스트로 접근할 때
|
|
75
|
+
* 서버가 Host 서브도메인을 추측하는 대신 명시적으로 테넌트를 지정한다(멀티테넌트 격리).
|
|
76
|
+
* 함수로 전달하면 매 요청마다 재평가.
|
|
77
|
+
*/
|
|
78
|
+
storeCode?: TokenSource;
|
|
79
|
+
/** 회원 Access Token. 함수로 전달하면 매 요청마다 재평가 */
|
|
80
|
+
accessToken?: TokenSource;
|
|
81
|
+
/**
|
|
82
|
+
* accessToken의 별칭 옵션 — `{ auth: { accessToken } }` 형태로 전달할 수 있다.
|
|
83
|
+
* 우선순위: 명시적 `accessToken` 옵션 > `auth.accessToken` > `session.accessToken`.
|
|
84
|
+
*/
|
|
85
|
+
auth?: { accessToken?: string };
|
|
86
|
+
/** 비회원 장바구니 토큰 */
|
|
87
|
+
cartToken?: TokenSource;
|
|
88
|
+
/** 서버가 새 X-Cart-Token을 발급했을 때 호출 */
|
|
89
|
+
onCartToken?: (token: string) => void;
|
|
90
|
+
/**
|
|
91
|
+
* SDK 세션 매니저 — 전달하면 accessToken(Bearer)·401 refresh·복구불가 시 clear를
|
|
92
|
+
* 자동 배선한다. `accessToken`/`auth.accessToken`을 명시하면 그쪽이 우선한다(하위호환).
|
|
93
|
+
*/
|
|
94
|
+
session?: StorefrontSession;
|
|
95
|
+
fetch?: typeof globalThis.fetch;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const productPageSchema = pageSchema(productCardSchema).extend({
|
|
99
|
+
facets: productFacetsSchema.optional(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const reviewPageSchema = pageSchema(publicReviewSchema).extend({
|
|
103
|
+
summary: reviewSummarySchema.optional(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export function createStorefrontClient(options: StorefrontClientOptions) {
|
|
107
|
+
const { session } = options;
|
|
108
|
+
|
|
109
|
+
// 우선순위: 명시적 accessToken 옵션 > auth.accessToken 별칭 > session.accessToken.
|
|
110
|
+
// session만 전달되고 나머지가 없으면 그쪽에서 배선(하위호환 — 기존 옵션 조합은 그대로 동작).
|
|
111
|
+
const accessTokenSource: TokenSource =
|
|
112
|
+
options.accessToken ??
|
|
113
|
+
options.auth?.accessToken ??
|
|
114
|
+
(session ? () => session.accessToken : undefined);
|
|
115
|
+
const refresh = session ? () => session.refresh() : undefined;
|
|
116
|
+
const onUnauthorized = session ? () => session.clear() : undefined;
|
|
117
|
+
|
|
118
|
+
const http = createHttp({
|
|
119
|
+
baseUrl: options.baseUrl,
|
|
120
|
+
fetch: options.fetch,
|
|
121
|
+
headers: () => {
|
|
122
|
+
const headers: Record<string, string> = {};
|
|
123
|
+
const storeCode = resolveToken(options.storeCode);
|
|
124
|
+
if (storeCode) headers["x-store-code"] = storeCode;
|
|
125
|
+
const accessToken = resolveToken(accessTokenSource);
|
|
126
|
+
if (accessToken) headers.authorization = `Bearer ${accessToken}`;
|
|
127
|
+
const cartToken = resolveToken(options.cartToken);
|
|
128
|
+
if (cartToken) headers["x-cart-token"] = cartToken;
|
|
129
|
+
return headers;
|
|
130
|
+
},
|
|
131
|
+
onResponse: (response) => {
|
|
132
|
+
const issued = response.headers.get("x-cart-token");
|
|
133
|
+
if (issued) options.onCartToken?.(issued);
|
|
134
|
+
},
|
|
135
|
+
refresh,
|
|
136
|
+
onUnauthorized,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
/** 현재 세션 accessToken (session 미전달 시 null) */
|
|
141
|
+
get accessToken(): string | null {
|
|
142
|
+
return session ? session.accessToken : null;
|
|
143
|
+
},
|
|
144
|
+
/** 현재 세션 refreshToken (session 미전달 시 null) */
|
|
145
|
+
get refreshToken(): string | null {
|
|
146
|
+
return session ? session.refreshToken : null;
|
|
147
|
+
},
|
|
148
|
+
/** session.userinfo() 위임 — session 미전달 시 에러 */
|
|
149
|
+
userinfo() {
|
|
150
|
+
if (!session) throw new Error("세션 없음 — createStorefrontSession 필요");
|
|
151
|
+
return session.userinfo();
|
|
152
|
+
},
|
|
153
|
+
auth: {
|
|
154
|
+
signup: (body: SignupRequest) =>
|
|
155
|
+
http.request("POST", "/auth/signup", tokenPairSchema, {
|
|
156
|
+
body: signupRequestSchema.parse(body),
|
|
157
|
+
}),
|
|
158
|
+
login: (body: LoginRequest) =>
|
|
159
|
+
http.request("POST", "/auth/login", tokenPairSchema, {
|
|
160
|
+
body: loginRequestSchema.parse(body),
|
|
161
|
+
}),
|
|
162
|
+
refresh: (refreshToken: string) =>
|
|
163
|
+
http.request("POST", "/auth/token/refresh", tokenPairSchema, { body: { refreshToken } }),
|
|
164
|
+
logout: () => http.requestVoid("POST", "/auth/logout"),
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
catalog: {
|
|
168
|
+
listCategories: () => http.request("GET", "/categories", z.array(categoryNodeSchema)),
|
|
169
|
+
searchProducts: (params?: ProductSearchParams) =>
|
|
170
|
+
http.request("GET", "/products", productPageSchema, { query: params as Query }),
|
|
171
|
+
getProduct: (productId: string) =>
|
|
172
|
+
http.request("GET", path`/products/${productId}`, productDetailSchema),
|
|
173
|
+
listProductReviews: (
|
|
174
|
+
productId: string,
|
|
175
|
+
params?: PageParams & {
|
|
176
|
+
rating?: number;
|
|
177
|
+
hasImage?: boolean;
|
|
178
|
+
sort?: "latest" | "ratingDesc" | "ratingAsc" | "helpful";
|
|
179
|
+
},
|
|
180
|
+
) =>
|
|
181
|
+
http.request("GET", path`/products/${productId}/reviews`, reviewPageSchema, {
|
|
182
|
+
query: params as Query,
|
|
183
|
+
}),
|
|
184
|
+
listProductInquiries: (productId: string, params?: PageParams) =>
|
|
185
|
+
http.request(
|
|
186
|
+
"GET",
|
|
187
|
+
path`/products/${productId}/inquiries`,
|
|
188
|
+
pageSchema(publicInquirySchema),
|
|
189
|
+
{
|
|
190
|
+
query: params as Query,
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
createInquiry: (productId: string, body: CreateInquiryRequest) =>
|
|
194
|
+
http.requestVoid("POST", path`/products/${productId}/inquiries`, { body }),
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
cart: {
|
|
198
|
+
get: () => http.request("GET", "/cart", cartSchema),
|
|
199
|
+
addItem: (body: AddCartItemRequest) =>
|
|
200
|
+
http.request("POST", "/cart/items", cartSchema, {
|
|
201
|
+
body: addCartItemRequestSchema.parse(body),
|
|
202
|
+
}),
|
|
203
|
+
updateItem: (cartItemId: string, body: UpdateCartItemRequest) =>
|
|
204
|
+
http.request("PATCH", path`/cart/items/${cartItemId}`, cartSchema, {
|
|
205
|
+
body: updateCartItemRequestSchema.parse(body),
|
|
206
|
+
}),
|
|
207
|
+
removeItem: (cartItemId: string) =>
|
|
208
|
+
http.requestVoid("DELETE", path`/cart/items/${cartItemId}`),
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
checkout: {
|
|
212
|
+
create: (body: CreateCheckoutRequest) =>
|
|
213
|
+
http.request("POST", "/checkout", checkoutSessionSchema, {
|
|
214
|
+
body: createCheckoutRequestSchema.parse(body),
|
|
215
|
+
}),
|
|
216
|
+
/**
|
|
217
|
+
* 결제 시작 — 결제 세션을 만들고 결제 팝업 URL(`pgParams.popupUrl`)을 돌려준다.
|
|
218
|
+
* `pgProvider`는 첫 결제 시도의 PG다(현재 `tosspayments`·`portone`, 새 PG가 추가돼도 파싱은 깨지지 않는다).
|
|
219
|
+
*
|
|
220
|
+
* PG 관련 에러(`ApiError.code`) — 둘 다 "결제 수단 준비 중" 안내를 권장한다.
|
|
221
|
+
* - 409 `PAYMENT_NOT_CONFIGURED`: 스토어에 사용 중인 PG가 없다. 결제 세션을 만들지 않는다
|
|
222
|
+
* - 503 `PAYMENT_PROVIDER_UNAVAILABLE`: 사용 중인 PG가 모두 준비 장애다. 잠시 후 재시도할 수 있다
|
|
223
|
+
*/
|
|
224
|
+
requestPayment: (checkoutId: string, body: RequestPaymentRequest) =>
|
|
225
|
+
http.request("POST", path`/checkout/${checkoutId}/payment`, paymentParamsSchema, {
|
|
226
|
+
body: requestPaymentRequestSchema.parse(body),
|
|
227
|
+
}),
|
|
228
|
+
/**
|
|
229
|
+
* 결제 승인 확정 — 같은 `paymentId + pgToken` 재호출은 저장된 결과를 돌려준다(멱등).
|
|
230
|
+
* `options.attemptId`는 결제창이 돌려준 주문번호(시도 id)이고, 생략하면 현재 시도를 승인한다.
|
|
231
|
+
*
|
|
232
|
+
* 요청 본문은 보내기 전에 `confirmPaymentRequestSchema`로 검증한다. 빈 `pgToken`처럼 형식이 틀리면
|
|
233
|
+
* 요청을 보내지 않고 **동기로 ZodError를 던진다**(Promise reject 아님 — 다른 메서드의 요청 검증과 같다).
|
|
234
|
+
*
|
|
235
|
+
* 카드 거절(402 `PG_DECLINED` 등)·402 `PAYMENT_NOT_APPROVED`·502 `PG_REQUEST_FAILED`·
|
|
236
|
+
* 503 `PG_CREDENTIALS_UNAVAILABLE` 뒤에도 세션은 `pending`으로 남아 다시 시도할 수 있다.
|
|
237
|
+
* 409 `LATE_APPROVAL_CANCELED`(미승인으로 판정된 뒤 늦게 온 승인을 서버가 PG 취소함)도 세션이 `pending`으로 남는다.
|
|
238
|
+
* 409 `PAYMENT_RESULT_UNKNOWN`은 결과 확인 중이라 재시도하면 안 된다. 그 밖의 409:
|
|
239
|
+
* `ATTEMPT_NOT_CURRENT`·`INVALID_ATTEMPT_STATE`·`PAYMENT_SESSION_OUTDATED`·`PG_AMOUNT_MISMATCH`.
|
|
240
|
+
* 503 `PG_MERCHANT_MISMATCH`: 승인 시도의 가맹점과 현재 저장된 자격 증명의 가맹점이 다르다.
|
|
241
|
+
*/
|
|
242
|
+
confirmPayment: (paymentId: string, pgToken: string, options?: { attemptId?: string }) =>
|
|
243
|
+
http.request("POST", path`/payments/${paymentId}/confirm`, confirmPaymentResultSchema, {
|
|
244
|
+
body: confirmPaymentRequestSchema.parse({ pgToken, attemptId: options?.attemptId }),
|
|
245
|
+
}),
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
payments: {
|
|
249
|
+
/**
|
|
250
|
+
* `GET /storefront/v1/payments/{paymentId}` — 결제 상태 조회. 결제 팝업이 결과(postMessage)를 알리지 못하고 닫혔을 때
|
|
251
|
+
* 부모 창이 `expiresAt`까지 폴링해 결과를 확인한다. `completed`면 `orderId`로 완료 화면, `processing`이면 계속 폴링,
|
|
252
|
+
* `pending`이면 기존 안내, `failed`·`expired`면 실패 안내다. TTL이 지난 `pending`은 `expired`로 보고된다.
|
|
253
|
+
* `testPayment`는 완료면 주문 플래그, 아니면 세션 환경이다.
|
|
254
|
+
*
|
|
255
|
+
* 에러: 404 `PAYMENT_NOT_FOUND`(없음·다른 스토어) · 410 `STORE_CLOSED` · 503 `STORE_SUSPENDED`
|
|
256
|
+
*/
|
|
257
|
+
getStatus: (paymentId: string) =>
|
|
258
|
+
http.request("GET", path`/payments/${paymentId}`, paymentStatusSchema),
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
myOrders: {
|
|
262
|
+
list: (params?: PageParams & { status?: OrderItemStatus; from?: string }) =>
|
|
263
|
+
http.request("GET", "/me/orders", pageSchema(myOrderSchema), { query: params as Query }),
|
|
264
|
+
get: (orderId: string) => http.request("GET", path`/me/orders/${orderId}`, myOrderSchema),
|
|
265
|
+
getGuestOrder: (orderId: string, body: { phone: string; orderPassword: string }) =>
|
|
266
|
+
http.request("POST", path`/guest/orders/${orderId}`, myOrderSchema, { body }),
|
|
267
|
+
getDelivery: (orderItemId: string) =>
|
|
268
|
+
http.request("GET", path`/me/order-items/${orderItemId}/delivery`, deliveryTrackingSchema),
|
|
269
|
+
decidePurchase: (orderItemId: string) =>
|
|
270
|
+
http.requestVoid("POST", path`/me/order-items/${orderItemId}/purchase-decision`),
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
myClaims: {
|
|
274
|
+
create: (orderItemId: string, body: CreateClaimRequest) =>
|
|
275
|
+
http.request("POST", path`/me/order-items/${orderItemId}/claims`, createClaimResultSchema, {
|
|
276
|
+
body: createClaimRequestSchema.parse(body),
|
|
277
|
+
}),
|
|
278
|
+
list: (params?: PageParams & { status?: ClaimStatus }) =>
|
|
279
|
+
http.request("GET", "/me/claims", pageSchema(myClaimSchema), { query: params as Query }),
|
|
280
|
+
withdraw: (claimId: string) => http.requestVoid("POST", path`/me/claims/${claimId}/withdraw`),
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
member: {
|
|
284
|
+
me: () => http.request("GET", "/me", memberSchema),
|
|
285
|
+
updateMe: (body: UpdateProfileRequest) =>
|
|
286
|
+
http.requestVoid("PATCH", "/me", { body: updateProfileRequestSchema.parse(body) }),
|
|
287
|
+
listAddresses: () => http.request("GET", "/me/addresses", z.array(memberAddressSchema)),
|
|
288
|
+
createAddress: (body: MemberAddressRequest) =>
|
|
289
|
+
http.requestVoid("POST", "/me/addresses", { body: memberAddressRequestSchema.parse(body) }),
|
|
290
|
+
updateAddress: (addressId: string, body: MemberAddressRequest) =>
|
|
291
|
+
http.requestVoid("PUT", path`/me/addresses/${addressId}`, {
|
|
292
|
+
body: memberAddressRequestSchema.parse(body),
|
|
293
|
+
}),
|
|
294
|
+
deleteAddress: (addressId: string) =>
|
|
295
|
+
http.requestVoid("DELETE", path`/me/addresses/${addressId}`),
|
|
296
|
+
listWishlist: (params?: PageParams) =>
|
|
297
|
+
http.request("GET", "/me/wishlist", pageSchema(productCardSchema), {
|
|
298
|
+
query: params as Query,
|
|
299
|
+
}),
|
|
300
|
+
addWishlist: (productId: string) =>
|
|
301
|
+
http.requestVoid("POST", "/me/wishlist", { body: { productId } }),
|
|
302
|
+
removeWishlist: (productId: string) =>
|
|
303
|
+
http.requestVoid("DELETE", path`/me/wishlist/${productId}`),
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
reviews: {
|
|
307
|
+
listWritable: () =>
|
|
308
|
+
http.request("GET", "/me/reviews/writable", z.array(writableReviewSchema)),
|
|
309
|
+
create: (orderItemId: string, body: CreateReviewRequest) =>
|
|
310
|
+
http.request(
|
|
311
|
+
"POST",
|
|
312
|
+
path`/me/order-items/${orderItemId}/review`,
|
|
313
|
+
createReviewResultSchema,
|
|
314
|
+
{
|
|
315
|
+
body: createReviewRequestSchema.parse(body),
|
|
316
|
+
},
|
|
317
|
+
),
|
|
318
|
+
update: (reviewId: string, body: UpdateReviewRequest) =>
|
|
319
|
+
http.requestVoid("PUT", path`/me/reviews/${reviewId}`, {
|
|
320
|
+
body: updateReviewRequestSchema.parse(body),
|
|
321
|
+
}),
|
|
322
|
+
remove: (reviewId: string) => http.requestVoid("DELETE", path`/me/reviews/${reviewId}`),
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
inquiries: {
|
|
326
|
+
listMine: (params?: PageParams & { answered?: boolean }) =>
|
|
327
|
+
http.request("GET", "/me/inquiries", pageSchema(publicInquirySchema), {
|
|
328
|
+
query: params as Query,
|
|
329
|
+
}),
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
me: {
|
|
333
|
+
customerInquiries: {
|
|
334
|
+
create: (body: CreateCustomerInquiryRequest) =>
|
|
335
|
+
http.request("POST", "/me/customer-inquiries", customerInquirySchema, {
|
|
336
|
+
body: createCustomerInquiryRequestSchema.parse(body),
|
|
337
|
+
}),
|
|
338
|
+
list: (params?: PageParams & { answered?: boolean; category?: CustomerInquiryCategory }) =>
|
|
339
|
+
http.request("GET", "/me/customer-inquiries", pageSchema(customerInquirySchema), {
|
|
340
|
+
query: params as Query,
|
|
341
|
+
}),
|
|
342
|
+
get: (inquiryId: string) =>
|
|
343
|
+
http.request("GET", path`/me/customer-inquiries/${inquiryId}`, customerInquirySchema),
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export type StorefrontClient = ReturnType<typeof createStorefrontClient>;
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** 표준 엔벨로프의 $.error 상세 */
|
|
4
|
+
export const errorDetailSchema = z.object({
|
|
5
|
+
code: z.string(),
|
|
6
|
+
message: z.string(),
|
|
7
|
+
traceId: z.string().optional(),
|
|
8
|
+
/**
|
|
9
|
+
* 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의
|
|
10
|
+
* `{ conditions: LiveGateCondition[] }`. 형태는 에러 코드마다 다르므로 소비측이 코드별 스키마로 다시 파싱한다
|
|
11
|
+
*/
|
|
12
|
+
details: z.record(z.string(), z.unknown()).optional().catch(undefined),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** 표준 응답 엔벨로프 — $.meta / $.data / $.error */
|
|
16
|
+
export const envelopeSchema = z.object({
|
|
17
|
+
meta: z.object({
|
|
18
|
+
status: z.number(),
|
|
19
|
+
code: z.string(),
|
|
20
|
+
message: z.string(),
|
|
21
|
+
isSuccess: z.boolean(),
|
|
22
|
+
}),
|
|
23
|
+
data: z.unknown().optional(),
|
|
24
|
+
/** 구형 필드 — data로 개명되기 전 응답과의 전환기 호환 */
|
|
25
|
+
content: z.unknown().optional(),
|
|
26
|
+
error: errorDetailSchema.nullable(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type ApiErrorBody = z.infer<typeof errorDetailSchema>;
|
|
30
|
+
|
|
31
|
+
/** 표준 엔벨로프면 $.data(구형 $.content)를, 아니면(평면 응답) 본문 전체를 반환 */
|
|
32
|
+
export function unwrapData(json: unknown): unknown {
|
|
33
|
+
const envelope = envelopeSchema.safeParse(json);
|
|
34
|
+
if (!envelope.success) return json;
|
|
35
|
+
return envelope.data.data ?? envelope.data.content ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ApiError extends Error {
|
|
39
|
+
constructor(
|
|
40
|
+
public readonly status: number,
|
|
41
|
+
public readonly code: string,
|
|
42
|
+
message: string,
|
|
43
|
+
public readonly traceId?: string,
|
|
44
|
+
public readonly body?: unknown,
|
|
45
|
+
/** 엔벨로프 `$.error.details` — 에러 코드별 보조 데이터. 없으면 undefined */
|
|
46
|
+
public readonly details?: Record<string, unknown>,
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ApiError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 비-2xx 응답 본문(raw JSON)을 표준 ApiError로 변환한다 — http 클라이언트와 session 매니저가
|
|
55
|
+
* **동일한 에러 형태**(status·code·message·traceId)를 던지도록 하는 단일 원천.
|
|
56
|
+
* 표준 엔벨로프($.error) 우선, 구형 평면 에러({ code, message })는 폴백.
|
|
57
|
+
*/
|
|
58
|
+
export function toApiError(status: number, raw: unknown): ApiError {
|
|
59
|
+
const envelope = envelopeSchema.safeParse(raw);
|
|
60
|
+
const detail = envelope.success
|
|
61
|
+
? envelope.data.error
|
|
62
|
+
: (errorDetailSchema.safeParse(raw).data ?? null);
|
|
63
|
+
if (detail) {
|
|
64
|
+
return new ApiError(status, detail.code, detail.message, detail.traceId, raw, detail.details);
|
|
65
|
+
}
|
|
66
|
+
return new ApiError(status, "UNKNOWN", `HTTP ${status}`, undefined, raw);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 경로 템플릿 태그 — 보간 값(경로 파라미터)마다 encodeURIComponent를 적용한다.
|
|
71
|
+
* `/`·`?`·`#` 등이 섞인 id가 다른 경로·쿼리로 해석되지 않게 한다. 예: path`/payments/${id}`
|
|
72
|
+
* (store-sdk `path`와 같은 구현)
|
|
73
|
+
*/
|
|
74
|
+
export function path(strings: TemplateStringsArray, ...params: Array<string | number>): string {
|
|
75
|
+
return strings.reduce(
|
|
76
|
+
(acc, part, i) => acc + part + (i < params.length ? encodeURIComponent(String(params[i])) : ""),
|
|
77
|
+
"",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TokenSource = string | null | undefined | (() => string | null | undefined);
|
|
82
|
+
|
|
83
|
+
export function resolveToken(source: TokenSource): string | null {
|
|
84
|
+
const value = typeof source === "function" ? source() : source;
|
|
85
|
+
return value ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface HttpOptions {
|
|
89
|
+
baseUrl: string;
|
|
90
|
+
fetch?: typeof globalThis.fetch;
|
|
91
|
+
headers?: () => Record<string, string>;
|
|
92
|
+
onResponse?: (response: Response) => void;
|
|
93
|
+
/**
|
|
94
|
+
* 401 시 호출 — 세션을 갱신하고 true면 요청을 **1회 재시도**한다(headers 콜백이 새 토큰을 읽는다).
|
|
95
|
+
* false거나 재시도 후에도 401이면 onUnauthorized로 넘어간다.
|
|
96
|
+
*/
|
|
97
|
+
refresh?: () => Promise<boolean>;
|
|
98
|
+
/** 복구 불가능한 401(갱신 실패/재시도 후에도 401) 시 호출 — 재로그인 유도용 */
|
|
99
|
+
onUnauthorized?: () => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type Query = Record<string, string | number | boolean | undefined>;
|
|
103
|
+
|
|
104
|
+
export function buildQuery(query?: Query): string {
|
|
105
|
+
if (!query) return "";
|
|
106
|
+
const params = new URLSearchParams();
|
|
107
|
+
for (const [key, value] of Object.entries(query)) {
|
|
108
|
+
if (value !== undefined) params.set(key, String(value));
|
|
109
|
+
}
|
|
110
|
+
const qs = params.toString();
|
|
111
|
+
return qs ? `?${qs}` : "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RequestOptions {
|
|
115
|
+
query?: Query;
|
|
116
|
+
body?: unknown;
|
|
117
|
+
headers?: Record<string, string>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Http {
|
|
121
|
+
request<T>(
|
|
122
|
+
method: string,
|
|
123
|
+
path: string,
|
|
124
|
+
schema: z.ZodType<T>,
|
|
125
|
+
options?: RequestOptions,
|
|
126
|
+
): Promise<T>;
|
|
127
|
+
requestVoid(method: string, path: string, options?: RequestOptions): Promise<void>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createHttp(options: HttpOptions): Http {
|
|
131
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
132
|
+
|
|
133
|
+
async function send(
|
|
134
|
+
method: string,
|
|
135
|
+
path: string,
|
|
136
|
+
request?: RequestOptions,
|
|
137
|
+
retried = false,
|
|
138
|
+
): Promise<Response> {
|
|
139
|
+
const url = `${options.baseUrl}${path}${buildQuery(request?.query)}`;
|
|
140
|
+
const headers: Record<string, string> = {
|
|
141
|
+
...(request?.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
142
|
+
...options.headers?.(),
|
|
143
|
+
...request?.headers,
|
|
144
|
+
};
|
|
145
|
+
const response = await fetchFn(url, {
|
|
146
|
+
method,
|
|
147
|
+
headers,
|
|
148
|
+
body: request?.body !== undefined ? JSON.stringify(request.body) : undefined,
|
|
149
|
+
});
|
|
150
|
+
options.onResponse?.(response);
|
|
151
|
+
// 401 → 세션 갱신 후 1회 재시도. headers 콜백이 갱신된 토큰을 읽으므로 재시도는 새 토큰으로 나간다.
|
|
152
|
+
if (response.status === 401 && !retried && options.refresh) {
|
|
153
|
+
if (await options.refresh()) return send(method, path, request, true);
|
|
154
|
+
}
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
if (response.status === 401) options.onUnauthorized?.();
|
|
157
|
+
const raw = await response.json().catch(() => null);
|
|
158
|
+
throw toApiError(response.status, raw);
|
|
159
|
+
}
|
|
160
|
+
return response;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
async request<T>(
|
|
165
|
+
method: string,
|
|
166
|
+
path: string,
|
|
167
|
+
schema: z.ZodType<T>,
|
|
168
|
+
request?: RequestOptions,
|
|
169
|
+
): Promise<T> {
|
|
170
|
+
const response = await send(method, path, request);
|
|
171
|
+
const json: unknown = await response.json();
|
|
172
|
+
return schema.parse(unwrapData(json));
|
|
173
|
+
},
|
|
174
|
+
async requestVoid(method: string, path: string, request?: RequestOptions): Promise<void> {
|
|
175
|
+
await send(method, path, request);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type { StorefrontClient, StorefrontClientOptions } from "./client";
|
|
2
|
+
export { createStorefrontClient } from "./client";
|
|
3
|
+
export type { Query, TokenSource } from "./http";
|
|
4
|
+
export { ApiError, buildQuery } from "./http";
|
|
5
|
+
export * from "./schemas/auth";
|
|
6
|
+
export * from "./schemas/cart";
|
|
7
|
+
export * from "./schemas/catalog";
|
|
8
|
+
export * from "./schemas/checkout";
|
|
9
|
+
export * from "./schemas/claim";
|
|
10
|
+
export * from "./schemas/common";
|
|
11
|
+
export * from "./schemas/customer-inquiry";
|
|
12
|
+
export * from "./schemas/inquiry";
|
|
13
|
+
export * from "./schemas/member";
|
|
14
|
+
export * from "./schemas/order";
|
|
15
|
+
export * from "./schemas/review";
|
|
16
|
+
export {
|
|
17
|
+
createStorefrontSession,
|
|
18
|
+
type SessionStorage,
|
|
19
|
+
STOREFRONT_SESSION_KEYS,
|
|
20
|
+
type StorefrontSession,
|
|
21
|
+
type StorefrontSessionOptions,
|
|
22
|
+
} from "./session";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const tokenPairSchema = z.object({
|
|
4
|
+
accessToken: z.string(),
|
|
5
|
+
refreshToken: z.string(),
|
|
6
|
+
expiresIn: z.number().int(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type TokenPair = z.infer<typeof tokenPairSchema>;
|
|
10
|
+
|
|
11
|
+
export const signupRequestSchema = z.object({
|
|
12
|
+
email: z.email(),
|
|
13
|
+
password: z
|
|
14
|
+
.string()
|
|
15
|
+
.min(10, "비밀번호는 10자 이상이어야 합니다")
|
|
16
|
+
.refine(
|
|
17
|
+
(value) =>
|
|
18
|
+
[/[a-zA-Z]/, /[0-9]/, /[^a-zA-Z0-9]/].filter((pattern) => pattern.test(value)).length >= 2,
|
|
19
|
+
"영문/숫자/특수문자 중 2종 이상을 조합해야 합니다",
|
|
20
|
+
),
|
|
21
|
+
name: z.string().min(1).max(50),
|
|
22
|
+
phone: z
|
|
23
|
+
.string()
|
|
24
|
+
.regex(/^01[0-9]{8,9}$/, "휴대폰 번호 형식이 아닙니다 (예: 01012345678)")
|
|
25
|
+
.optional(),
|
|
26
|
+
agreements: z.object({
|
|
27
|
+
terms: z.literal(true),
|
|
28
|
+
privacy: z.literal(true),
|
|
29
|
+
marketing: z.boolean().default(false),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type SignupRequest = z.infer<typeof signupRequestSchema>;
|
|
34
|
+
|
|
35
|
+
export const loginRequestSchema = z.object({
|
|
36
|
+
email: z.email(),
|
|
37
|
+
password: z.string().min(1),
|
|
38
|
+
cartToken: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type LoginRequest = z.infer<typeof loginRequestSchema>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const cartItemSchema = z.object({
|
|
4
|
+
cartItemId: z.string(),
|
|
5
|
+
productId: z.string(),
|
|
6
|
+
productName: z.string(),
|
|
7
|
+
thumbnailUrl: z.url(),
|
|
8
|
+
optionId: z.string().nullable(),
|
|
9
|
+
optionName: z.string().nullable(),
|
|
10
|
+
quantity: z.number().int(),
|
|
11
|
+
unitPrice: z.number().int(),
|
|
12
|
+
totalPrice: z.number().int(),
|
|
13
|
+
purchasable: z.boolean(),
|
|
14
|
+
unpurchasableReason: z.enum(["SOLD_OUT", "SUSPENDED", "CLOSED"]).nullable(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type CartItem = z.infer<typeof cartItemSchema>;
|
|
18
|
+
|
|
19
|
+
export const cartSchema = z.object({
|
|
20
|
+
items: z.array(cartItemSchema),
|
|
21
|
+
summary: z.object({
|
|
22
|
+
productAmount: z.number().int(),
|
|
23
|
+
deliveryFee: z.number().int(),
|
|
24
|
+
totalAmount: z.number().int(),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type Cart = z.infer<typeof cartSchema>;
|
|
29
|
+
|
|
30
|
+
export const addCartItemRequestSchema = z.object({
|
|
31
|
+
productId: z.string(),
|
|
32
|
+
optionId: z.string().optional(),
|
|
33
|
+
quantity: z.number().int().min(1).max(999),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type AddCartItemRequest = z.infer<typeof addCartItemRequestSchema>;
|
|
37
|
+
|
|
38
|
+
export const updateCartItemRequestSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
quantity: z.number().int().min(1).max(999).optional(),
|
|
41
|
+
optionId: z.string().optional(),
|
|
42
|
+
})
|
|
43
|
+
.refine((value) => Object.values(value).some((v) => v !== undefined), {
|
|
44
|
+
message: "최소 1개 필드가 필요합니다",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type UpdateCartItemRequest = z.infer<typeof updateCartItemRequestSchema>;
|