@sayren/store-sdk 0.33.0 → 0.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -63,9 +63,12 @@ import { myAccountSchema, myStoreSchema } from "./schemas/auth";
63
63
  import {
64
64
  type CategoryCreateRequest,
65
65
  type CategoryDeleteStrategy,
66
+ type CategoryLocalizationRequest,
66
67
  type CategoryPatchRequest,
67
68
  categoryAttributeSchema,
68
69
  categoryCreateRequestSchema,
70
+ categoryLocalizationRequestSchema,
71
+ categoryLocalizationSchema,
69
72
  categoryPatchRequestSchema,
70
73
  categorySchema,
71
74
  categorySearchResultSchema,
@@ -307,6 +310,18 @@ import {
307
310
  variantStockRequestSchema,
308
311
  variantStockSchema,
309
312
  } from "./schemas/product";
313
+ import {
314
+ type InfoSectionRequestInput,
315
+ infoSchemaSchema,
316
+ infoSectionRequestSchema,
317
+ infoSectionSchema,
318
+ platformCodeSchema,
319
+ } from "./schemas/product-info";
320
+ import {
321
+ type ProductLocalizationRequest,
322
+ productLocalizationRequestSchema,
323
+ productLocalizationSchema,
324
+ } from "./schemas/product-localization";
310
325
  import {
311
326
  type SavedOptionSetFromProductRequestInput,
312
327
  type SavedOptionSetListParams,
@@ -585,6 +600,24 @@ export function createStoreClient(options: StoreClientOptions) {
585
600
  body: productPatchRequestSchema.parse(body),
586
601
  }),
587
602
  remove: (productId: string) => storeHttp.requestVoid("DELETE", path`/products/${productId}`),
603
+ /** 번역(이슈 #134) — 언어별 상품명·상세설명·이미지·옵션 이름 */
604
+ localizations: {
605
+ list: (productId: string) =>
606
+ storeHttp.request(
607
+ "GET",
608
+ path`/products/${productId}/localizations`,
609
+ z.array(productLocalizationSchema),
610
+ ),
611
+ put: (productId: string, locale: string, body: ProductLocalizationRequest) =>
612
+ storeHttp.request(
613
+ "PUT",
614
+ path`/products/${productId}/localizations/${locale}`,
615
+ productLocalizationSchema,
616
+ { body: productLocalizationRequestSchema.parse(body) },
617
+ ),
618
+ remove: (productId: string, locale: string) =>
619
+ storeHttp.requestVoid("DELETE", path`/products/${productId}/localizations/${locale}`),
620
+ },
588
621
  bulkUpdateStatus: (productIds: string[], status: ProductStatus, idempotencyKey?: string) =>
589
622
  storeHttp.request("POST", "/products/bulk/status", bulkResultSchema, {
590
623
  body: { productIds, status: productStatusSchema.parse(status) },
@@ -670,6 +703,42 @@ export function createStoreClient(options: StoreClientOptions) {
670
703
  ),
671
704
  },
672
705
 
706
+ /**
707
+ * 상품정보제공 고시 품목군(이슈 #135) — 플랫폼 코드 표. 현재 버전만 싣는다. 권한은 상품 조회(`product:r`)다
708
+ */
709
+ infoSchemas: {
710
+ list: (params?: { kind?: string }) =>
711
+ storeHttp.request("GET", "/info-schemas", z.array(infoSchemaSchema), {
712
+ query: params as Query,
713
+ }),
714
+ },
715
+
716
+ /** 플랫폼 코드 묶음(이슈 #135) — 인증 종류·대상·면제 사유(`PLATFORM_CODE_SETS`). 권한은 상품 조회(`product:r`)다 */
717
+ codes: {
718
+ list: (setId: string) =>
719
+ storeHttp.request("GET", path`/codes/${setId}`, z.array(platformCodeSchema)),
720
+ },
721
+
722
+ /**
723
+ * 안내 섹션(이슈 #135) — 여러 상품이 같이 쓰는 글. 상품은 `infoSectionIds`로 연결만 하고, 섹션을 고치면 연결 상품 전체가 바뀐다.
724
+ * 지우면 연결도 풀린다. 권한은 상품과 같다(`product:r`·`product:rw`)
725
+ */
726
+ infoSections: {
727
+ list: () => storeHttp.request("GET", "/info-sections", z.array(infoSectionSchema)),
728
+ get: (infoSectionId: string) =>
729
+ storeHttp.request("GET", path`/info-sections/${infoSectionId}`, infoSectionSchema),
730
+ create: (body: InfoSectionRequestInput) =>
731
+ storeHttp.request("POST", "/info-sections", infoSectionSchema, {
732
+ body: infoSectionRequestSchema.parse(body),
733
+ }),
734
+ update: (infoSectionId: string, body: InfoSectionRequestInput) =>
735
+ storeHttp.request("PUT", path`/info-sections/${infoSectionId}`, infoSectionSchema, {
736
+ body: infoSectionRequestSchema.parse(body),
737
+ }),
738
+ remove: (infoSectionId: string) =>
739
+ storeHttp.requestVoid("DELETE", path`/info-sections/${infoSectionId}`),
740
+ },
741
+
673
742
  /**
674
743
  * 컬렉션 (이슈 #83) — 상품을 어디에 보여 줄지 정하는 묶음(다대다). 권한은 카테고리와 같다(`category:r`·`category:rw`).
675
744
  * 쓰기마다 `revision`이 오른다. 수정 본문에 읽은 `revision`을 주면 그 사이 다른 수정이 있을 때 409 `COLLECTION_CHANGED`다.
@@ -753,6 +822,24 @@ export function createStoreClient(options: StoreClientOptions) {
753
822
  storeHttp.requestVoid("DELETE", path`/categories/${categoryId}`, {
754
823
  query: (opts?.strategy ? { strategy: opts.strategy } : {}) as Query,
755
824
  }),
825
+ /** 번역(이슈 #134) — 언어별 이름·SEO */
826
+ localizations: {
827
+ list: (categoryId: string) =>
828
+ storeHttp.request(
829
+ "GET",
830
+ path`/categories/${categoryId}/localizations`,
831
+ z.array(categoryLocalizationSchema),
832
+ ),
833
+ put: (categoryId: string, locale: string, body: CategoryLocalizationRequest) =>
834
+ storeHttp.request(
835
+ "PUT",
836
+ path`/categories/${categoryId}/localizations/${locale}`,
837
+ categoryLocalizationSchema,
838
+ { body: categoryLocalizationRequestSchema.parse(body) },
839
+ ),
840
+ remove: (categoryId: string, locale: string) =>
841
+ storeHttp.requestVoid("DELETE", path`/categories/${categoryId}/localizations/${locale}`),
842
+ },
756
843
  },
757
844
 
758
845
  orders: {
@@ -1172,7 +1259,7 @@ export function createStoreClient(options: StoreClientOptions) {
1172
1259
  ),
1173
1260
  /**
1174
1261
  * `POST /v1/customers/{customerId}/points/adjustments` (201) — 스코프 `promotion:rw`. 양수 지급, 음수 차감. `idempotencyKey`가 필수다.
1175
- * 에러: 409 `POINT_BALANCE_INSUFFICIENT`·`POINTS_UNAVAILABLE`, 400 `POINT_EXPIRY_REQUIRED`. 에이전트 요청은 승인 대상이다
1262
+ * 에러: 409 `POINT_BALANCE_INSUFFICIENT`, 400 `POINT_EXPIRY_REQUIRED`. 에이전트 요청은 승인 대상이다
1176
1263
  */
1177
1264
  adjustPoints: (customerId: string, body: PointAdjustmentRequest, idempotencyKey: string) =>
1178
1265
  storeHttp.request(
@@ -1476,14 +1563,13 @@ export function createStoreClient(options: StoreClientOptions) {
1476
1563
  * - 409 `PAYMENT_PROVIDER_INCOMPLETE`: 켜려는데 활성 환경(샌드박스면 TEST, 아니면 LIVE) 세트가 없거나 불완전
1477
1564
  * - 409 `LIVE_GATE_NOT_MET`: `sandboxMode: false`(라이브 전환)인데 개방 조건 미충족. `ApiError.details`가
1478
1565
  * `{ conditions: LiveGateCondition[] }`(조건별 `met`)이다. `liveGateErrorDetailsSchema`로 파싱한다
1479
- * - 409 `LIVE_PAYMENTS_DISABLED`: 플랫폼 실결제 스위치만 미충족. `details`는 위와 같다
1480
1566
  *
1481
1567
  * `sandboxMode: true`는 항상 허용되고 즉시 실결제가 멈춘다.
1482
1568
  */
1483
1569
  updateProvider: updatePaymentProvider,
1484
1570
  /**
1485
1571
  * 샌드박스 스위치만 바꾼다 — `updateProvider(provider, { sandboxMode })`와 같다.
1486
- * `false`(라이브 전환)는 409 `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`(둘 다 `ApiError.details.conditions`)가 날 수 있다.
1572
+ * `false`(라이브 전환)는 409 `LIVE_GATE_NOT_MET`(`ApiError.details.conditions`)가 날 수 있다.
1487
1573
  */
1488
1574
  setSandboxMode: (provider: PgProvider, sandboxMode: boolean) =>
1489
1575
  updatePaymentProvider(provider, { sandboxMode }),
@@ -1933,7 +2019,7 @@ export function createStoreClient(options: StoreClientOptions) {
1933
2019
  storeHttp.request("PUT", "/storefront/template", storeTemplateSchema, {
1934
2020
  body: updateStoreTemplateRequestSchema.parse(body),
1935
2021
  }),
1936
- /** `GET /v1/storefront/site` — 스위치 상태와 현재 사이트(없으면 `site: null`) */
2022
+ /** `GET /v1/storefront/site` — 호스팅 설정과 현재 사이트(없으면 `site: null`) */
1937
2023
  get: () => storeHttp.request("GET", "/storefront/site", storefrontHostingSchema),
1938
2024
  /** `GET /v1/storefront/slug-availability?slug=` — 슬러그를 쓸 수 있는가 */
1939
2025
  checkSlug: (slug: string) =>
@@ -2204,7 +2290,7 @@ export function createStoreClient(options: StoreClientOptions) {
2204
2290
  getPointPolicy: () => storeHttp.request("GET", "/store/point-policy", storePointPolicySchema),
2205
2291
  /**
2206
2292
  * `PUT /v1/store/point-policy` — 스코프 `promotion:rw`. 준 항목만 바꾼다. 에이전트 요청은 승인 대상이다.
2207
- * 에러: 409 `POINTS_UNAVAILABLE`(준비 중) · 400 `POINT_EXPIRY_REQUIRED`(리뷰 적립을 켜는데 유효 기간이 없음)
2293
+ * 에러: 400 `POINT_EXPIRY_REQUIRED`(리뷰 적립을 켜는데 유효 기간이 없음)
2208
2294
  */
2209
2295
  updatePointPolicy: (body: UpdatePointPolicyRequest) =>
2210
2296
  storeHttp.request("PUT", "/store/point-policy", storePointPolicySchema, {
package/src/http.ts CHANGED
@@ -12,7 +12,7 @@ export const errorDetailSchema = z.object({
12
12
  */
13
13
  docs: z.url().optional().catch(undefined),
14
14
  /**
15
- * 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의
15
+ * 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`의
16
16
  * `{ conditions: LiveGateCondition[] }`. 형태는 에러 코드마다 다르므로 소비측이 코드별 스키마로 다시 파싱한다
17
17
  */
18
18
  details: z.record(z.string(), z.unknown()).optional().catch(undefined),
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export * from "./schemas/customer";
36
36
  export * from "./schemas/customer-delegation";
37
37
  export * from "./schemas/fulfillment";
38
38
  export * from "./schemas/inventory";
39
+ export * from "./schemas/locale";
39
40
  export * from "./schemas/login";
40
41
  export * from "./schemas/market";
41
42
  export * from "./schemas/misc";
@@ -43,6 +44,8 @@ export * from "./schemas/order";
43
44
  export * from "./schemas/payment";
44
45
  export * from "./schemas/points";
45
46
  export * from "./schemas/product";
47
+ export * from "./schemas/product-info";
48
+ export * from "./schemas/product-localization";
46
49
  export * from "./schemas/promotion";
47
50
  export * from "./schemas/refund-policy";
48
51
  export * from "./schemas/saved-option-set";
@@ -146,3 +146,28 @@ export const categoryDeleteQuerySchema = z.object({
146
146
  "`reparent`는 직계 자식을 삭제할 카테고리의 부모로 옮기고, `cascade`는 서브트리를 삭제하고 연결 상품을 미분류로 둔다",
147
147
  ),
148
148
  });
149
+
150
+ /**
151
+ * 카테고리 번역 (이슈 #134) — 언어 하나의 이름·SEO. 카테고리 필드(`name`·`meta*`)는 상점 기본 언어 값이고 번역 레코드가 언어별 값이다.
152
+ * 비운 칸(null)은 스토어프론트가 기본 언어 값으로 대체한다(칸 단위 대체).
153
+ */
154
+ export const categoryLocalizationSchema = z.object({
155
+ locale: z.string().describe("언어(BCP 47)"),
156
+ name: z.string().nullable().describe("이름. null이면 기본 언어 이름"),
157
+ metaTitle: z.string().nullable(),
158
+ metaDescription: z.string().nullable(),
159
+ metaKeywords: z.string().nullable(),
160
+ updatedAt: z.string(),
161
+ });
162
+
163
+ export type CategoryLocalization = z.infer<typeof categoryLocalizationSchema>;
164
+
165
+ /** `PUT /v1/categories/{categoryId}/localizations/{locale}` 본문 — 레코드 전체를 바꾼다. 빠진 칸은 null(기본 언어 값으로 대체)이다 */
166
+ export const categoryLocalizationRequestSchema = z.strictObject({
167
+ name: z.string().trim().min(1).max(100).nullable().optional(),
168
+ metaTitle: z.string().max(100).nullable().optional(),
169
+ metaDescription: z.string().max(300).nullable().optional(),
170
+ metaKeywords: z.string().max(200).nullable().optional(),
171
+ });
172
+
173
+ export type CategoryLocalizationRequest = z.infer<typeof categoryLocalizationRequestSchema>;
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 언어 코드 (이슈 #134) — BCP 47의 언어[-문자][-지역]까지 받는다(`ko`·`en`·`en-US`·`zh-Hant-TW`).
5
+ * 대소문자는 표준 표기로 맞춘다(언어 소문자, 문자 첫 글자 대문자, 지역 대문자). 맞지 않으면 null이다.
6
+ */
7
+ const LOCALE_PATTERN = /^([a-z]{2,3})(?:-([a-z]{4}))?(?:-([a-z]{2}))?$/;
8
+
9
+ export function normalizeLocale(value: string | null | undefined): string | null {
10
+ const match = value?.trim().replace(/_/g, "-").toLowerCase().match(LOCALE_PATTERN);
11
+ if (!match) return null;
12
+ const [, language, script, region] = match;
13
+ return [
14
+ language,
15
+ script ? `${script[0]?.toUpperCase()}${script.slice(1)}` : null,
16
+ region ? region.toUpperCase() : null,
17
+ ]
18
+ .filter(Boolean)
19
+ .join("-");
20
+ }
21
+
22
+ /** 상점 기본 언어 — 옛 상점과 언어를 정하지 않은 상점의 값이다 */
23
+ export const DEFAULT_STORE_LOCALE = "ko";
24
+
25
+ export const localeCodeSchema = z
26
+ .string()
27
+ .max(35)
28
+ .transform((value, ctx) => {
29
+ const normalized = normalizeLocale(value);
30
+ if (normalized === null) {
31
+ ctx.addIssue({
32
+ code: "custom",
33
+ message: "언어는 BCP 47 언어 코드입니다(예: ko·en·en-US)",
34
+ });
35
+ return z.NEVER;
36
+ }
37
+ return normalized;
38
+ })
39
+ .describe("언어(BCP 47, 예: ko·en·en-US·zh-Hant-TW)");
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { currencyCodeSchema } from "./currency";
3
+ import { localeCodeSchema } from "./locale";
3
4
 
4
5
  /**
5
6
  * 판매 권역 (이슈 #130) — 상점이 어느 국가에 어느 통화로 파는지를 정한다.
@@ -9,6 +10,8 @@ import { currencyCodeSchema } from "./currency";
9
10
  *
10
11
  * 스토어프론트는 요청마다 도메인(`X-Storefront-Host`) → 구매자 국가(`X-Buyer-Country`) → 기본 권역 순으로 권역을 고르고, 주문서가 그 권역·통화를 굳힌다.
11
12
  * 외화 권역은 적립금을 켤 수 없다(적립금은 기본 통화 단위다).
13
+ *
14
+ * 권역은 언어도 정한다(이슈 #134). `locales`는 이 권역 스토어프론트가 내는 언어이고 첫 언어가 대표 언어다. 비어 있으면 상점 기본 언어 하나다.
12
15
  */
13
16
 
14
17
  const countryCodeSchema = z
@@ -44,6 +47,13 @@ export const marketSchema = z.object({
44
47
  .string()
45
48
  .nullable()
46
49
  .describe("보관 시각. 보관한 권역은 판정에서 빠지고 그 국가·도메인은 풀린다"),
50
+ locales: z
51
+ .array(z.string())
52
+ .optional()
53
+ .default([])
54
+ .describe(
55
+ "이 권역 언어(이슈 #134). 첫 언어가 대표 언어다. 비어 있으면 상점 기본 언어 하나다. 서버는 항상 싣는다",
56
+ ),
47
57
  });
48
58
 
49
59
  export type Market = z.infer<typeof marketSchema>;
@@ -55,6 +65,13 @@ export const createMarketRequestSchema = z.strictObject({
55
65
  domains: z.array(hostnameSchema).max(20).optional(),
56
66
  couponsEnabled: z.boolean().optional().describe("기본 false"),
57
67
  pointsEnabled: z.boolean().optional().describe("기본 false. 외화 권역은 true로 둘 수 없다"),
68
+ locales: z
69
+ .array(localeCodeSchema)
70
+ .max(20)
71
+ .optional()
72
+ .describe(
73
+ "이 권역 언어(이슈 #134). 주면 목록 전체를 바꾼다. 첫 언어가 대표 언어다. 비우면 상점 기본 언어 하나다",
74
+ ),
58
75
  });
59
76
 
60
77
  export type CreateMarketRequest = z.infer<typeof createMarketRequestSchema>;
@@ -70,6 +87,13 @@ export const updateMarketRequestSchema = z.strictObject({
70
87
  domains: z.array(hostnameSchema).max(20).optional().describe("주면 도메인 목록 전체를 바꾼다"),
71
88
  couponsEnabled: z.boolean().optional(),
72
89
  pointsEnabled: z.boolean().optional().describe("외화 권역은 true로 둘 수 없다"),
90
+ locales: z
91
+ .array(localeCodeSchema)
92
+ .max(20)
93
+ .optional()
94
+ .describe(
95
+ "이 권역 언어(이슈 #134). 주면 목록 전체를 바꾼다. 첫 언어가 대표 언어다. 비우면 상점 기본 언어 하나다",
96
+ ),
73
97
  });
74
98
 
75
99
  export type UpdateMarketRequest = z.infer<typeof updateMarketRequestSchema>;
@@ -284,6 +284,13 @@ export const orderSchema = z.object({
284
284
  .describe(
285
285
  "테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았습니다. 상품을 발송하지 않도록 주의하십시오",
286
286
  ),
287
+ locale: z
288
+ .string()
289
+ .nullable()
290
+ .optional()
291
+ .describe(
292
+ "구매 언어(이슈 #134, BCP 47). 주문상품의 상품명·옵션 이름은 구매자가 이 언어로 본 값이다. null이면 상점 기본 언어다(언어 기능 이전 주문 포함)",
293
+ ),
287
294
  items: z.array(orderItemSchema),
288
295
  });
289
296
 
@@ -310,7 +317,7 @@ const pageQuery = {
310
317
 
311
318
  /**
312
319
  * `GET /v1/orders` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열). 검증 규칙은 서버가 적용한다:
313
- * `orderNo` 없이 `orderedFrom` 누락 400 `MISSING_PARAMETER`, 날짜·`orderNo` 형식 오류 400 `INVALID_PARAMETER`, 31일 초과 400 `RANGE_TOO_WIDE`,
320
+ * `orderNo` 없이 `orderedFrom` 누락 400 `MISSING_PARAMETER`, 날짜·`orderNo` 형식 오류 400 `INVALID_PARAMETER`, 366일 초과 400 `RANGE_TOO_WIDE`,
314
321
  * `testPayment`가 `true`·`false`가 아니면 400 `INVALID_PARAMETER`
315
322
  */
316
323
  export const orderListQuerySchema = z.object({
@@ -321,7 +328,7 @@ export const orderListQuerySchema = z.object({
321
328
  orderedTo: z
322
329
  .string()
323
330
  .optional()
324
- .describe("조회 끝 시각 (ISO 8601, 포함). 생략하면 orderedFrom부터 31일. 기간은 최대 31일"),
331
+ .describe("조회 끝 시각 (ISO 8601, 포함). 생략하면 orderedFrom부터 31일. 기간은 최대 366일"),
325
332
  status: orderItemStatusSchema
326
333
  .optional()
327
334
  .describe("주문상품 상태 필터 — 이 상태의 주문상품이 하나라도 있는 주문"),
@@ -410,7 +410,7 @@ export const liveGateConditionCodeSchema = z
410
410
  .describe(
411
411
  "라이브 개방 조건. `TEST_CONNECTION`=TEST 세트 연결 테스트 성공, `SANDBOX_PAYMENT`=현재 TEST 세트로 샌드박스 결제 1건 승인, " +
412
412
  "`SANDBOX_REFUND`=그 샌드박스 결제의 환불 1건 성공, `LIVE_CONNECTION`=LIVE 세트 연결 테스트 성공, " +
413
- "`PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠, `NOT_TEMPLATE_STORE`=템플릿 상점이 아님(템플릿 상점에만 싣고 늘 미충족), " +
413
+ "`PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠(늘 충족), `NOT_TEMPLATE_STORE`=템플릿 상점이 아님(템플릿 상점에만 싣고 늘 미충족), " +
414
414
  "`NOT_DEVELOPMENT_STORE`=앱 개발 상점이 아님(앱 개발 상점에만 싣고 늘 미충족)",
415
415
  );
416
416
  export type LiveGateConditionCode = z.infer<typeof liveGateConditionCodeSchema>;
@@ -432,7 +432,7 @@ export const liveGateSchema = z.object({
432
432
  export type LiveGate = z.infer<typeof liveGateSchema>;
433
433
 
434
434
  /**
435
- * 409 `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의 `ApiError.details` 형태.
435
+ * 409 `LIVE_GATE_NOT_MET`의 `ApiError.details` 형태.
436
436
  * 응답 파싱용이라 관대하다 — 알 수 없는 키는 버리고, 형태가 다르면 safeParse 실패로 처리한다.
437
437
  */
438
438
  export const liveGateErrorDetailsSchema = z.object({
@@ -210,9 +210,7 @@ export type PointPolicyNotice = z.infer<typeof pointPolicyNoticeSchema>;
210
210
  export const storePointPolicySchema = z.object({
211
211
  available: z
212
212
  .boolean()
213
- .describe(
214
- "적립금 설정을 바꿀 수 있는가. false면 준비 중이라 정책 저장이 409 `POINTS_UNAVAILABLE`이고 모든 적립이 멈춰 있다",
215
- ),
213
+ .describe("적립금 설정을 바꿀 수 있는가. 지금은 늘 true다(이전 응답 호환 필드)"),
216
214
  configured: z.boolean().describe("셀러가 정책을 저장한 적이 있는가. false면 기본(모두 끔)이다"),
217
215
  reviewReward: reviewRewardPolicySchema.describe("리뷰 적립. 정하지 않았으면 끔·0원"),
218
216
  purchaseEarn: purchaseEarnPolicySchema.describe(
@@ -223,8 +221,7 @@ export const storePointPolicySchema = z.object({
223
221
  .optional()
224
222
  .default(false)
225
223
  .describe(
226
- "적립금 사용(결제에 쓰기)을 켤 수 있는가 (#93). false면 준비 중이라 `use.enabled`를 켜는 저장이 409 `POINTS_UNAVAILABLE`이다. " +
227
- "필드가 없는 이전 응답은 false로 읽는다",
224
+ "적립금 사용(결제에 쓰기)을 켤 수 있는가 (#93). 지금은 늘 true다. 필드가 없는 이전 응답은 false로 읽는다",
228
225
  ),
229
226
  use: pointUsePolicySchema
230
227
  .optional()
@@ -0,0 +1,190 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 상품정보제공 고시·인증 정보·안내 섹션 (이슈 #135).
5
+ *
6
+ * **코드 값은 문자열이다.** 품목군(`schemaId`)·인증 종류·대상·면제 사유는 플랫폼 코드 표(서버 DB)가 원천이고, 코드가 늘어도 이 SDK의
7
+ * 계약이 깨지지 않게 zod enum으로 막지 않는다. 서버가 코드 표에 있는지 검사한다(없으면 400 `INVALID_INFO_CODE`).
8
+ * enum으로 두는 값은 서버가 그 값으로 분기하는 인증 상태(`CERTIFICATION_STATUSES`)뿐이다.
9
+ */
10
+
11
+ // ── 플랫폼 코드 표 ────────────────────────────────────────────────
12
+
13
+ /**
14
+ * 칸 값의 출처 — 셀러가 비운 칸을 서버가 채운다. 서버가 분기하는 값이라 형태가 정해져 있지만, 새 출처가 늘 수 있어 응답은 열린 객체다.
15
+ * - `legal`: 법정 문구(`text`). 셀러가 적지 않는다
16
+ * - `option`: 이름이 `names` 중 하나인 조합 옵션명의 값 목록
17
+ * - `store`: 상점 필드(`field`, 예: `customerCenterPhone`)
18
+ */
19
+ export const infoFieldSourceSchema = z.looseObject({
20
+ type: z.string(),
21
+ text: z.string().optional(),
22
+ names: z.array(z.string()).optional(),
23
+ field: z.string().optional(),
24
+ });
25
+
26
+ export type InfoFieldSource = z.infer<typeof infoFieldSourceSchema>;
27
+
28
+ export const infoSchemaFieldSchema = z.object({
29
+ key: z.string(),
30
+ label: z.string(),
31
+ required: z
32
+ .boolean()
33
+ .describe("법정 필수 칸인가. 서버는 비어 있어도 막지 않는다(표시는 「상품상세 참조」)"),
34
+ maxLength: z.number().int(),
35
+ source: infoFieldSourceSchema.nullable().optional().describe("값 출처. 없으면 셀러가 적는 칸"),
36
+ });
37
+
38
+ export type InfoSchemaField = z.infer<typeof infoSchemaFieldSchema>;
39
+
40
+ /** 정형 정보 스키마 — 고시 품목군 하나(`kind: "notice"`). 칸 구성은 버전마다 고정이고 상품은 저장한 버전으로 표시된다 */
41
+ export const infoSchemaSchema = z.object({
42
+ schemaId: z.string().describe("품목군 코드(예: `WEAR`)"),
43
+ version: z.number().int(),
44
+ kind: z.string().describe("스키마 종류. 지금은 `notice`(상품정보제공 고시)"),
45
+ name: z.string(),
46
+ fallback: z.string().describe("비운 칸의 표시 문구(예: 「상품상세 참조」)"),
47
+ fields: z.array(infoSchemaFieldSchema).describe("칸. 배열 순서가 표시 순서다"),
48
+ });
49
+
50
+ export type InfoSchema = z.infer<typeof infoSchemaSchema>;
51
+
52
+ /** 플랫폼 코드 하나 — 코드 묶음(`setId`) 안에서 유일하다. `attrs`는 묶음마다 다른 부가 정보다 */
53
+ export const platformCodeSchema = z.object({
54
+ code: z.string(),
55
+ label: z.string(),
56
+ attrs: z
57
+ .record(z.string(), z.unknown())
58
+ .describe(
59
+ "부가 정보. `certification.subject`는 `kinds`(이 대상을 충족하는 인증 종류)·`exemptions`(허용 면제 사유)를 싣는다",
60
+ ),
61
+ });
62
+
63
+ export type PlatformCode = z.infer<typeof platformCodeSchema>;
64
+
65
+ /** 코드 묶음 — `GET /v1/codes/{setId}` */
66
+ export const PLATFORM_CODE_SETS = {
67
+ certificationKind: "certification.kind",
68
+ certificationSubject: "certification.subject",
69
+ certificationExemption: "certification.exemption",
70
+ } as const;
71
+
72
+ // ── 상품 고시 ────────────────────────────────────────────────────
73
+
74
+ export const productNoticeSchema = z.object({
75
+ schemaId: z.string(),
76
+ version: z.number().int(),
77
+ values: z.record(z.string(), z.string()).describe("칸 키 → 셀러가 적은 값. 비운 칸은 없다"),
78
+ });
79
+
80
+ export type ProductNotice = z.infer<typeof productNoticeSchema>;
81
+
82
+ export const MAX_NOTICE_VALUE_LENGTH = 1500;
83
+
84
+ export const productNoticeRequestSchema = z.object({
85
+ schemaId: z.string().trim().min(1).max(50).describe("품목군 코드(`GET /v1/info-schemas`)"),
86
+ version: z.number().int().positive().optional().describe("품목군 버전. 생략하면 현재 버전"),
87
+ values: z
88
+ .record(z.string().max(50), z.string().max(MAX_NOTICE_VALUE_LENGTH))
89
+ .default({})
90
+ .describe(
91
+ "칸 키 → 값. 출처가 `legal`인 칸은 받지 않는다(400). 빈 문자열은 비운 칸으로 저장하지 않는다. 칸 길이는 스키마 `maxLength`",
92
+ ),
93
+ });
94
+
95
+ export type ProductNoticeRequest = z.infer<typeof productNoticeRequestSchema>;
96
+
97
+ // ── 상품 인증 ────────────────────────────────────────────────────
98
+
99
+ /** 인증 대상 상태 — 서버가 분기한다(대상이면 인증서 필수, 면제면 사유 필수) */
100
+ export const CERTIFICATION_STATUSES = ["REQUIRED", "NOT_REQUIRED", "EXEMPT"] as const;
101
+
102
+ export type CertificationStatus = (typeof CERTIFICATION_STATUSES)[number];
103
+
104
+ export const MAX_CERTIFICATION_ITEMS = 20;
105
+
106
+ export const productCertificationsSchema = z.object({
107
+ subjects: z
108
+ .array(
109
+ z.object({
110
+ subject: z.string().describe("인증 대상 코드(`certification.subject`)"),
111
+ status: z
112
+ .string()
113
+ .describe("`REQUIRED`·`NOT_REQUIRED`·`EXEMPT`. 값이 늘 수 있어 문자열이다"),
114
+ exemption: z.string().nullable().describe("면제 사유 코드. `EXEMPT`일 때만 있다"),
115
+ }),
116
+ )
117
+ .describe("인증 대상 여부. 없는 대상은 `NOT_REQUIRED`다"),
118
+ items: z
119
+ .array(
120
+ z.object({
121
+ kind: z.string().describe("인증 종류 코드(`certification.kind`)"),
122
+ agency: z.string().nullable().describe("인증 기관"),
123
+ number: z.string().describe("인증 번호"),
124
+ }),
125
+ )
126
+ .describe("인증서. 배열 순서가 표시 순서다"),
127
+ });
128
+
129
+ export type ProductCertifications = z.infer<typeof productCertificationsSchema>;
130
+
131
+ export const productCertificationsRequestSchema = z.object({
132
+ subjects: z
133
+ .array(
134
+ z.object({
135
+ subject: z.string().trim().min(1).max(50),
136
+ status: z.enum(CERTIFICATION_STATUSES),
137
+ exemption: z.string().trim().min(1).max(50).nullish(),
138
+ }),
139
+ )
140
+ .max(20)
141
+ .default([])
142
+ .describe(
143
+ "인증 대상 여부. `REQUIRED`인데 그 대상의 인증 종류(`attrs.kinds`) 인증서가 `items`에 없으면 400 `CERTIFICATION_REQUIRED`, " +
144
+ "`EXEMPT`인데 사유가 없거나 대상이 허용하지 않는 사유면 400 `CERTIFICATION_EXEMPTION_INVALID`",
145
+ ),
146
+ items: z
147
+ .array(
148
+ z.object({
149
+ kind: z.string().trim().min(1).max(50),
150
+ agency: z.string().trim().max(100).nullish(),
151
+ number: z.string().trim().min(1).max(100),
152
+ }),
153
+ )
154
+ .max(MAX_CERTIFICATION_ITEMS)
155
+ .default([]),
156
+ });
157
+
158
+ export type ProductCertificationsRequest = z.infer<typeof productCertificationsRequestSchema>;
159
+ export type ProductCertificationsRequestInput = z.input<typeof productCertificationsRequestSchema>;
160
+
161
+ // ── 안내 섹션 ────────────────────────────────────────────────────
162
+
163
+ /** 상품 하나에 연결할 수 있는 안내 섹션 수 */
164
+ export const MAX_PRODUCT_INFO_SECTIONS = 10;
165
+ export const MAX_INFO_SECTION_TITLE = 100;
166
+
167
+ /**
168
+ * 안내 섹션 — 사이즈 가이드·세탁 안내처럼 여러 상품이 같이 쓰는 글이다. **상품은 연결만 한다**(복사가 아니다).
169
+ * 섹션을 고치면 연결된 모든 상품의 표시가 바뀐다.
170
+ */
171
+ export const infoSectionSchema = z.object({
172
+ infoSectionId: z.string(),
173
+ title: z.string(),
174
+ body: z.string().describe("본문 HTML(상세설명과 같은 정화 규칙)"),
175
+ bodyJson: z.string().nullable().describe("에디터 복원용 직렬화 상태"),
176
+ assignedProductCount: z.number().int().describe("이 섹션을 연결한 상품 수"),
177
+ createdAt: z.string(),
178
+ updatedAt: z.string(),
179
+ });
180
+
181
+ export type InfoSection = z.infer<typeof infoSectionSchema>;
182
+
183
+ export const infoSectionRequestSchema = z.object({
184
+ title: z.string().trim().min(1).max(MAX_INFO_SECTION_TITLE),
185
+ body: z.string().default(""),
186
+ bodyJson: z.string().nullable().optional(),
187
+ });
188
+
189
+ export type InfoSectionRequest = z.infer<typeof infoSectionRequestSchema>;
190
+ export type InfoSectionRequestInput = z.input<typeof infoSectionRequestSchema>;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { MAX_PRODUCT_IMAGES, productImageUrlSchema } from "./product";
3
+
4
+ /**
5
+ * 상품 번역 (이슈 #134) — 언어 하나의 상품명·상세설명·이미지·옵션 이름. 상품 필드는 상점 기본 언어 값이고 번역 레코드가 언어별 값이다.
6
+ * 비운 칸(null)은 스토어프론트가 기본 언어 값으로 대체한다(칸 단위 대체). 이미지는 목록 통째로 대체한다.
7
+ * 옵션은 id가 원천이다 — 값 id·추가금·재고는 언어와 무관하고 이름만 언어별이다.
8
+ */
9
+
10
+ const translatedOptionValueSchema = z.object({
11
+ valueId: z.string(),
12
+ name: z.string().nullable().describe("값 이름. null이면 기본 언어 이름"),
13
+ });
14
+
15
+ const translatedOptionGroupSchema = z.object({
16
+ groupId: z.string(),
17
+ name: z.string().nullable().describe("옵션명. null이면 기본 언어 이름"),
18
+ values: z.array(translatedOptionValueSchema),
19
+ });
20
+
21
+ const translatedCustomInputSchema = z.object({
22
+ inputId: z.string(),
23
+ label: z.string().nullable().describe("항목명. null이면 기본 언어 항목명"),
24
+ placeholder: z.string().nullable().describe("입력 안내. null이면 기본 언어 안내"),
25
+ });
26
+
27
+ /**
28
+ * 조회 응답 — 지금 상품의 옵션명·값·직접 입력 항목을 모두 싣고 번역하지 않은 칸은 null이다.
29
+ * `optionGroups`는 조합 옵션, `addonGroups`는 추가 선택이다(상품 응답과 같은 구분)
30
+ */
31
+ export const productLocalizationSchema = z.object({
32
+ locale: z.string().describe("언어(BCP 47)"),
33
+ name: z.string().nullable().describe("상품명. null이면 기본 언어 상품명"),
34
+ description: z.string().nullable().describe("상세설명(HTML). null이면 기본 언어 상세설명"),
35
+ descriptionJson: z.string().nullable().describe("상세설명 에디터 상태(재편집 복원용)"),
36
+ images: z.array(z.string()).nullable().describe("이미지 목록. null이면 기본 언어 목록"),
37
+ optionGroups: z.array(translatedOptionGroupSchema),
38
+ addonGroups: z.array(translatedOptionGroupSchema),
39
+ customInputs: z.array(translatedCustomInputSchema),
40
+ updatedAt: z.string(),
41
+ });
42
+
43
+ export type ProductLocalization = z.infer<typeof productLocalizationSchema>;
44
+
45
+ const translatedNameSchema = z.string().trim().min(1).max(50).nullable().optional();
46
+
47
+ const translatedOptionGroupRequestSchema = z.strictObject({
48
+ groupId: z.string(),
49
+ name: translatedNameSchema,
50
+ values: z.array(z.strictObject({ valueId: z.string(), name: translatedNameSchema })).optional(),
51
+ });
52
+
53
+ /**
54
+ * `PUT /v1/products/{productId}/localizations/{locale}` 본문 — 레코드 전체를 바꾼다. 빠진 칸은 null(기본 언어 값으로 대체)이다.
55
+ * 옵션은 번역할 id만 보낸다. 상품에 없는 id는 400 `PRODUCT_LOCALIZATION_TARGET_NOT_FOUND`다.
56
+ * 번역한 옵션명·값 이름도 기본 언어와 같은 규칙(대소문자·앞뒤 공백 무시 유일)이고 겹치면 400 `INVALID_OPTION_MODEL`이다
57
+ */
58
+ export const productLocalizationRequestSchema = z.strictObject({
59
+ name: z.string().trim().min(1).max(100).nullable().optional(),
60
+ description: z.string().nullable().optional(),
61
+ descriptionJson: z.string().nullable().optional(),
62
+ images: z.array(productImageUrlSchema).min(1).max(MAX_PRODUCT_IMAGES).nullable().optional(),
63
+ optionGroups: z.array(translatedOptionGroupRequestSchema).optional(),
64
+ addonGroups: z.array(translatedOptionGroupRequestSchema).optional(),
65
+ customInputs: z
66
+ .array(
67
+ z.strictObject({
68
+ inputId: z.string(),
69
+ label: z.string().trim().min(1).max(30).nullable().optional(),
70
+ placeholder: z.string().trim().min(1).max(100).nullable().optional(),
71
+ }),
72
+ )
73
+ .optional(),
74
+ });
75
+
76
+ export type ProductLocalizationRequest = z.infer<typeof productLocalizationRequestSchema>;