@zalkera/client 0.7.2 → 0.9.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.cts CHANGED
@@ -67,6 +67,18 @@ interface SiteConfig {
67
67
  * 정책 문구와 이 값이 갈리지 않도록 금액은 항상 이 필드를 쓴다.
68
68
  */
69
69
  defaultReturnShippingFee: number | null;
70
+ /**
71
+ * 영업시간 JSON 문자열(스키마리스 — 테넌트가 채운다). 미설정이면 null.
72
+ * 권장 구조: `{weekly:[{dayOfWeek:["Monday",…], opens:"10:00", closes:"20:00"}], closedNote?:string}`.
73
+ *
74
+ * `dayOfWeek`·`opens`·`closes` 는 schema.org `OpeningHoursSpecification` 의 속성명을 그대로 쓴다 —
75
+ * 중간 어휘를 하나 더 만들면 매핑표가 사본이 되고, 사본은 갈라진다.
76
+ *
77
+ * **null 이면 JSON-LD 에서 필드를 통째로 빼라.** 영업시간을 모르는 채 "영업시간 있음"만 주장하는 것은
78
+ * 구조화 데이터로서 거짓이고, `LocalBusiness` 는 그 거짓이 곧바로 사람의 헛걸음이 되는 타입이다.
79
+ * **파싱 실패해도 죽으면 안 된다** — 패스스루라 어떤 값이든 올 수 있다.
80
+ */
81
+ openingHours: string | null;
70
82
  }
71
83
  /** 테넌트 업종. 서버가 값을 추가할 수 있다 — 모르는 값은 일반(Organization)으로 흘려보내라. */
72
84
  type BusinessType = "BEAUTY";
@@ -919,7 +931,7 @@ declare class ZalkeraError extends Error {
919
931
  * client 발행 전 `scripts/sync-section-contract.mjs` 로 대조한다.
920
932
  */
921
933
  /** 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다. */
922
- declare const SECTION_CONTRACT_REV = 1;
934
+ declare const SECTION_CONTRACT_REV = 2;
923
935
  /** 업종 분류 — 콘솔 섹션 픽커의 그룹핑에 쓴다(테넌트 업종 필드는 아직 없다·memo102 §5). */
924
936
  type SectionVertical = "BEAUTY" | "GENERAL";
925
937
  interface SectionSpec {
@@ -935,7 +947,7 @@ interface SectionSpec {
935
947
  declare const SECTION_CONTRACT: readonly [{
936
948
  readonly type: "SERVICE_MENU";
937
949
  readonly vertical: "BEAUTY";
938
- readonly jsonLd: null;
950
+ readonly jsonLd: "ItemList";
939
951
  }, {
940
952
  readonly type: "BEFORE_AFTER_GALLERY";
941
953
  readonly vertical: "BEAUTY";
@@ -1012,7 +1024,13 @@ declare function asObjectArray(value: unknown): Record<string, unknown>[];
1012
1024
  declare function asString(value: unknown): string | undefined;
1013
1025
  /** 양의 정수가 아니면 undefined. */
1014
1026
  declare function asId(value: unknown): number | undefined;
1015
- /** 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다). */
1027
+ /**
1028
+ * 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다).
1029
+ *
1030
+ * 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config
1031
+ * 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.
1032
+ * **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 인코딩만 하고 값을 판정하지 않는다.
1033
+ */
1016
1034
  declare function mediaSrc(assetId: number): string;
1017
1035
 
1018
1036
  /**
package/dist/index.d.ts CHANGED
@@ -67,6 +67,18 @@ interface SiteConfig {
67
67
  * 정책 문구와 이 값이 갈리지 않도록 금액은 항상 이 필드를 쓴다.
68
68
  */
69
69
  defaultReturnShippingFee: number | null;
70
+ /**
71
+ * 영업시간 JSON 문자열(스키마리스 — 테넌트가 채운다). 미설정이면 null.
72
+ * 권장 구조: `{weekly:[{dayOfWeek:["Monday",…], opens:"10:00", closes:"20:00"}], closedNote?:string}`.
73
+ *
74
+ * `dayOfWeek`·`opens`·`closes` 는 schema.org `OpeningHoursSpecification` 의 속성명을 그대로 쓴다 —
75
+ * 중간 어휘를 하나 더 만들면 매핑표가 사본이 되고, 사본은 갈라진다.
76
+ *
77
+ * **null 이면 JSON-LD 에서 필드를 통째로 빼라.** 영업시간을 모르는 채 "영업시간 있음"만 주장하는 것은
78
+ * 구조화 데이터로서 거짓이고, `LocalBusiness` 는 그 거짓이 곧바로 사람의 헛걸음이 되는 타입이다.
79
+ * **파싱 실패해도 죽으면 안 된다** — 패스스루라 어떤 값이든 올 수 있다.
80
+ */
81
+ openingHours: string | null;
70
82
  }
71
83
  /** 테넌트 업종. 서버가 값을 추가할 수 있다 — 모르는 값은 일반(Organization)으로 흘려보내라. */
72
84
  type BusinessType = "BEAUTY";
@@ -919,7 +931,7 @@ declare class ZalkeraError extends Error {
919
931
  * client 발행 전 `scripts/sync-section-contract.mjs` 로 대조한다.
920
932
  */
921
933
  /** 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다. */
922
- declare const SECTION_CONTRACT_REV = 1;
934
+ declare const SECTION_CONTRACT_REV = 2;
923
935
  /** 업종 분류 — 콘솔 섹션 픽커의 그룹핑에 쓴다(테넌트 업종 필드는 아직 없다·memo102 §5). */
924
936
  type SectionVertical = "BEAUTY" | "GENERAL";
925
937
  interface SectionSpec {
@@ -935,7 +947,7 @@ interface SectionSpec {
935
947
  declare const SECTION_CONTRACT: readonly [{
936
948
  readonly type: "SERVICE_MENU";
937
949
  readonly vertical: "BEAUTY";
938
- readonly jsonLd: null;
950
+ readonly jsonLd: "ItemList";
939
951
  }, {
940
952
  readonly type: "BEFORE_AFTER_GALLERY";
941
953
  readonly vertical: "BEAUTY";
@@ -1012,7 +1024,13 @@ declare function asObjectArray(value: unknown): Record<string, unknown>[];
1012
1024
  declare function asString(value: unknown): string | undefined;
1013
1025
  /** 양의 정수가 아니면 undefined. */
1014
1026
  declare function asId(value: unknown): number | undefined;
1015
- /** 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다). */
1027
+ /**
1028
+ * 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다).
1029
+ *
1030
+ * 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config
1031
+ * 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.
1032
+ * **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 인코딩만 하고 값을 판정하지 않는다.
1033
+ */
1016
1034
  declare function mediaSrc(assetId: number): string;
1017
1035
 
1018
1036
  /**
package/dist/index.js CHANGED
@@ -67,6 +67,11 @@ function isApiErrorBody(value) {
67
67
  return typeof value === "object" && value !== null && "message" in value && typeof value.message === "string";
68
68
  }
69
69
 
70
+ // src/pathSegment.ts
71
+ function seg(v) {
72
+ return encodeURIComponent(String(v));
73
+ }
74
+
70
75
  // src/client.ts
71
76
  var DEFAULT_TIMEOUT_MS = 1e4;
72
77
  function createZalkeraClient(options) {
@@ -79,6 +84,9 @@ function createZalkeraClient(options) {
79
84
  }
80
85
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
81
86
  async function request(path, init) {
87
+ if (/(^|\/)\.{1,2}(\/|$)/.test(path)) {
88
+ throw new ZalkeraError(`\uACBD\uB85C\uC5D0 \uC0C1\uB300 \uC138\uADF8\uBA3C\uD2B8\uAC00 \uC788\uC2B5\uB2C8\uB2E4: ${path}`, { status: 400, code: "INVALID_PATH" });
89
+ }
82
90
  const url = new URL(`${baseUrl}/api${path}`);
83
91
  if (init?.query) {
84
92
  for (const [key, value] of Object.entries(init.query)) {
@@ -104,7 +112,14 @@ function createZalkeraClient(options) {
104
112
  method: init?.method ?? "GET",
105
113
  headers,
106
114
  body: init?.body != null ? JSON.stringify(init.body) : void 0,
107
- signal: controller.signal
115
+ signal: controller.signal,
116
+ // 리다이렉트를 따라가지 않는다 — **보안 불변식이라 스위치를 두지 않는다**(옵션으로 열면
117
+ // 그 옵션이 곧 사고 경로가 된다). 기본값 `follow` 로 크로스오리진 3xx 를 따라가면
118
+ // `X-Storefront-Key`(테넌트 시크릿)·`X-Tenant`·`X-Cart-Session`·`Idempotency-Key` 가
119
+ // 제3자 오리진에 그대로 전달된다 — undici 는 커스텀 헤더를 벗기지 않는다(실측).
120
+ // `"error"` 를 쓰지 않는 이유: undici 에서 일반 `TypeError: fetch failed` 로 떨어져
121
+ // 아래 네트워크 분기에 흡수되고, 그러면 원인(대개 baseUrl 오배선)이 사라진다.
122
+ redirect: "manual"
108
123
  };
109
124
  if (init?.next) fetchInit.next = init.next;
110
125
  response = await fetchImpl(url.toString(), fetchInit);
@@ -117,6 +132,14 @@ function createZalkeraClient(options) {
117
132
  } finally {
118
133
  clearTimeout(timer);
119
134
  }
135
+ if (isRedirectResponse(response)) {
136
+ response.body?.cancel().catch(() => {
137
+ });
138
+ throw new ZalkeraError(
139
+ `\uBC31\uC5D4\uB4DC\uAC00 \uB9AC\uB2E4\uC774\uB809\uD2B8\uB85C \uC751\uB2F5\uD588\uC2B5\uB2C8\uB2E4 (HTTP ${response.status}). \uBCF4\uC548\uC0C1 \uB530\uB77C\uAC00\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uB530\uB77C\uAC00\uBA74 \uC694\uCCAD \uD5E4\uB354(\uD14C\uB10C\uD2B8 \uC2DC\uD06C\uB9BF \uD0A4\xB7\uC7A5\uBC14\uAD6C\uB2C8 \uC138\uC158)\uAC00 \uB2E4\uB978 \uC624\uB9AC\uC9C4\uC73C\uB85C \uC0D9\uB2C8\uB2E4. baseUrl \uC774 \uCD5C\uC885 \uC624\uB9AC\uC9C4\uC778\uC9C0 \uD655\uC778\uD558\uC138\uC694(http\u2194https \uC2B9\uACA9, www \uC720\uBB34, \uD504\uB85D\uC2DC\uC758 \uACBD\uB85C \uB9AC\uB77C\uC774\uD2B8).`,
140
+ { status: 502, code: "UPSTREAM_REDIRECT" }
141
+ );
142
+ }
120
143
  const text = await response.text();
121
144
  const parsed = text ? safeJsonParse(text) : null;
122
145
  if (!response.ok) {
@@ -142,18 +165,18 @@ function createZalkeraClient(options) {
142
165
  sort: params?.sort
143
166
  }
144
167
  }),
145
- getPost: (slug) => request(`/public/posts/${encodeURIComponent(slug)}`),
146
- recordPostView: (slug, context) => request(`/public/posts/${encodeURIComponent(slug)}/view`, {
168
+ getPost: (slug) => request(`/public/posts/${seg(slug)}`),
169
+ recordPostView: (slug, context) => request(`/public/posts/${seg(slug)}/view`, {
147
170
  method: "POST",
148
171
  context
149
172
  }),
150
- getPage: (slug, options2) => request(`/public/pages/${encodeURIComponent(slug)}`, nextInit(options2)),
173
+ getPage: (slug, options2) => request(`/public/pages/${seg(slug)}`, nextInit(options2)),
151
174
  listPages: (params, options2) => request("/public/pages", {
152
175
  query: { page: params?.page, size: params?.size },
153
176
  ...nextInit(options2)
154
177
  }),
155
178
  listMenus: (options2) => request("/public/menus", { next: options2?.tags ? { tags: options2.tags } : void 0 }),
156
- getMediaUrl: (id) => request(`/public/media/${id}/url`),
179
+ getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
157
180
  submitInquiry: (input, context) => request("/public/inquiries", {
158
181
  method: "POST",
159
182
  body: input,
@@ -165,7 +188,7 @@ function createZalkeraClient(options) {
165
188
  context
166
189
  }),
167
190
  // ── 커머스: 카탈로그 ────────────────────────────────────
168
- getProduct: (slug, options2) => request(`/public/products/${encodeURIComponent(slug)}`, nextInit(options2)),
191
+ getProduct: (slug, options2) => request(`/public/products/${seg(slug)}`, nextInit(options2)),
169
192
  listProducts: (params, options2) => request("/public/products", {
170
193
  query: {
171
194
  productType: params?.productType,
@@ -178,14 +201,14 @@ function createZalkeraClient(options) {
178
201
  }),
179
202
  listProductCategories: (options2) => request("/public/product-categories", nextInit(options2)),
180
203
  // ── 커머스: 상품후기(공개) ──────────────────────────────
181
- listProductReviews: (productId, params, options2) => request(`/public/products/${productId}/reviews`, {
204
+ listProductReviews: (productId, params, options2) => request(`/public/products/${seg(productId)}/reviews`, {
182
205
  query: { page: params?.page, size: params?.size },
183
206
  next: options2?.tags ? { tags: options2.tags } : void 0
184
207
  }),
185
- getProductReviewSummary: (productId, options2) => request(`/public/products/${productId}/reviews/summary`, {
208
+ getProductReviewSummary: (productId, options2) => request(`/public/products/${seg(productId)}/reviews/summary`, {
186
209
  next: options2?.tags ? { tags: options2.tags } : void 0
187
210
  }),
188
- createProductReview: (productId, input, accessToken) => request(`/shop/products/${productId}/reviews`, { method: "POST", body: input, bearer: accessToken }),
211
+ createProductReview: (productId, input, accessToken) => request(`/shop/products/${seg(productId)}/reviews`, { method: "POST", body: input, bearer: accessToken }),
189
212
  // ── 커머스: 고객 인증 ───────────────────────────────────
190
213
  socialLogin: (input) => request("/shop/auth/social", { method: "POST", body: input }),
191
214
  refreshSession: (refreshToken) => request("/shop/auth/refresh", { method: "POST", body: { refreshToken } }),
@@ -200,11 +223,11 @@ function createZalkeraClient(options) {
200
223
  }),
201
224
  createBooking: (accessToken, input) => request("/shop/booking/bookings", { method: "POST", body: input, bearer: accessToken }),
202
225
  myBookings: (accessToken) => request("/shop/booking/bookings", { bearer: accessToken }),
203
- cancelBooking: (accessToken, bookingCode) => request(`/shop/booking/bookings/${encodeURIComponent(bookingCode)}/cancel`, {
226
+ cancelBooking: (accessToken, bookingCode) => request(`/shop/booking/bookings/${seg(bookingCode)}/cancel`, {
204
227
  method: "POST",
205
228
  bearer: accessToken
206
229
  }),
207
- rescheduleBooking: (accessToken, bookingCode, newSlotId) => request(`/shop/booking/bookings/${encodeURIComponent(bookingCode)}/reschedule`, {
230
+ rescheduleBooking: (accessToken, bookingCode, newSlotId) => request(`/shop/booking/bookings/${seg(bookingCode)}/reschedule`, {
208
231
  method: "POST",
209
232
  body: { newSlotId },
210
233
  bearer: accessToken
@@ -212,33 +235,33 @@ function createZalkeraClient(options) {
212
235
  // ── 커머스: 장바구니 ────────────────────────────────────
213
236
  getCart: (session) => request("/shop/cart", shopInit(session)),
214
237
  addToCart: (variantId, quantity, session) => request("/shop/cart/items", shopInit(session, { method: "POST", body: { variantId, quantity } })),
215
- updateCartItem: (variantId, quantity, session) => request(`/shop/cart/items/${variantId}`, shopInit(session, { method: "PATCH", body: { quantity } })),
216
- removeFromCart: (variantId, session) => request(`/shop/cart/items/${variantId}`, shopInit(session, { method: "DELETE" })),
238
+ updateCartItem: (variantId, quantity, session) => request(`/shop/cart/items/${seg(variantId)}`, shopInit(session, { method: "PATCH", body: { quantity } })),
239
+ removeFromCart: (variantId, session) => request(`/shop/cart/items/${seg(variantId)}`, shopInit(session, { method: "DELETE" })),
217
240
  clearCart: (session) => request("/shop/cart", shopInit(session, { method: "DELETE" })),
218
241
  // ── 커머스: 결제·주문·배송 ──────────────────────────────
219
242
  checkout: (input, session, idempotencyKey) => request("/shop/checkout", shopInit(session, { method: "POST", body: input, idempotencyKey })),
220
243
  confirmPayment: (orderNo, providerParams, access) => request(
221
- `/shop/orders/${encodeURIComponent(orderNo)}/payment/confirm`,
244
+ `/shop/orders/${seg(orderNo)}/payment/confirm`,
222
245
  accessInit(access, { method: "POST", body: providerParams })
223
246
  ),
224
247
  startPayment: (orderNo, access) => request(
225
- `/shop/orders/${encodeURIComponent(orderNo)}/payment/session`,
248
+ `/shop/orders/${seg(orderNo)}/payment/session`,
226
249
  accessInit(access, { method: "POST" })
227
250
  ),
228
- getOrder: (orderNo, access) => request(`/shop/orders/${encodeURIComponent(orderNo)}`, accessInit(access)),
251
+ getOrder: (orderNo, access) => request(`/shop/orders/${seg(orderNo)}`, accessInit(access)),
229
252
  listMyOrders: (accessToken, params) => request("/shop/orders", {
230
253
  bearer: accessToken,
231
254
  query: { page: params?.page, size: params?.size }
232
255
  }),
233
256
  cancelOrder: (orderNo, access) => request(
234
- `/shop/orders/${encodeURIComponent(orderNo)}/cancel`,
257
+ `/shop/orders/${seg(orderNo)}/cancel`,
235
258
  accessInit(access, { method: "POST" })
236
259
  ),
237
260
  completeOrder: (orderNo, access) => request(
238
- `/shop/orders/${encodeURIComponent(orderNo)}/complete`,
261
+ `/shop/orders/${seg(orderNo)}/complete`,
239
262
  accessInit(access, { method: "POST" })
240
263
  ),
241
- getShipment: (orderNo, access) => request(`/shop/orders/${encodeURIComponent(orderNo)}/shipment`, accessInit(access))
264
+ getShipment: (orderNo, access) => request(`/shop/orders/${seg(orderNo)}/shipment`, accessInit(access))
242
265
  };
243
266
  }
244
267
  function nextInit(options) {
@@ -254,6 +277,9 @@ function accessInit(access, extra) {
254
277
  query: access.phone ? { phone: access.phone } : void 0
255
278
  };
256
279
  }
280
+ function isRedirectResponse(response) {
281
+ return response.type === "opaqueredirect" || response.status >= 300 && response.status < 400;
282
+ }
257
283
  function safeJsonParse(text) {
258
284
  try {
259
285
  return JSON.parse(text);
@@ -263,10 +289,13 @@ function safeJsonParse(text) {
263
289
  }
264
290
 
265
291
  // src/sections.ts
266
- var SECTION_CONTRACT_REV = 1;
292
+ var SECTION_CONTRACT_REV = 2;
267
293
  var SECTION_CONTRACT = [
268
294
  // 뷰티(memo47) — append-only 계약상 불변
269
- { type: "SERVICE_MENU", vertical: "BEAUTY", jsonLd: null },
295
+ // SERVICE_MENU ItemList 는 rev 2 에서 올라왔다(memo119 T6-ⓒ): 쇼핑몰 유형엔 상품 목록 표면을
296
+ // 요구하면서 예약 유형엔 시술 목록 표면이 없던 비대칭의 해소다. 뷰티 사이트에서 시술 목록의
297
+ // 정위치는 별도 라우트가 아니라 홈의 이 섹션이라, FAQ_LIST→FAQPage 와 같은 형태로 섹션이 직접 낸다.
298
+ { type: "SERVICE_MENU", vertical: "BEAUTY", jsonLd: "ItemList" },
270
299
  { type: "BEFORE_AFTER_GALLERY", vertical: "BEAUTY", jsonLd: null },
271
300
  { type: "BOOKING_CTA", vertical: "BEAUTY", jsonLd: null },
272
301
  { type: "DOCTOR_INTRO", vertical: "BEAUTY", jsonLd: null },
@@ -325,7 +354,7 @@ function asId(value) {
325
354
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
326
355
  }
327
356
  function mediaSrc(assetId) {
328
- return `/media/${assetId}`;
357
+ return `/media/${seg(assetId)}`;
329
358
  }
330
359
 
331
360
  // src/theme.ts