@sayren/store-sdk 0.35.0 → 0.37.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.35.0",
3
+ "version": "0.37.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -87,11 +87,14 @@ import {
87
87
  import {
88
88
  type CollectionCreateRequestInput,
89
89
  type CollectionListParams,
90
+ type CollectionLocalizationRequest,
90
91
  type CollectionPatchRequest,
91
92
  type CollectionPreviewRequest,
92
93
  type CollectionProductsAddRequest,
93
94
  type CollectionProductsReplaceRequest,
94
95
  collectionCreateRequestSchema,
96
+ collectionLocalizationRequestSchema,
97
+ collectionLocalizationSchema,
95
98
  collectionPageSchema,
96
99
  collectionPatchRequestSchema,
97
100
  collectionPreviewRequestSchema,
@@ -277,6 +280,7 @@ import {
277
280
  type VerifyPaymentProviderRequest,
278
281
  verifyPaymentProviderRequestSchema,
279
282
  } from "./schemas/payment";
283
+ import { publicPlansSchema } from "./schemas/plans";
280
284
  import {
281
285
  customerPointsSchema,
282
286
  type PointAdjustmentRequest,
@@ -311,8 +315,11 @@ import {
311
315
  variantStockSchema,
312
316
  } from "./schemas/product";
313
317
  import {
318
+ type InfoSectionLocalizationRequest,
314
319
  type InfoSectionRequestInput,
315
320
  infoSchemaSchema,
321
+ infoSectionLocalizationRequestSchema,
322
+ infoSectionLocalizationSchema,
316
323
  infoSectionRequestSchema,
317
324
  infoSectionSchema,
318
325
  platformCodeSchema,
@@ -325,9 +332,12 @@ import {
325
332
  import {
326
333
  type SavedOptionSetFromProductRequestInput,
327
334
  type SavedOptionSetListParams,
335
+ type SavedOptionSetLocalizationRequest,
328
336
  type SavedOptionSetRequestInput,
329
337
  savedOptionSetFromProductRequestSchema,
330
338
  savedOptionSetFromProductResultSchema,
339
+ savedOptionSetLocalizationRequestSchema,
340
+ savedOptionSetLocalizationSchema,
331
341
  savedOptionSetRequestSchema,
332
342
  savedOptionSetSchema,
333
343
  } from "./schemas/saved-option-set";
@@ -360,12 +370,14 @@ import {
360
370
  addStorefrontDomainRequestSchema,
361
371
  type ChangeStorefrontSlugRequest,
362
372
  type ChangeStorefrontTemplateRequest,
373
+ type ConnectStorefrontGitRequest,
363
374
  type CreateStorefrontEditPreviewLinkRequest,
364
375
  type CreateStorefrontPreviewLinkRequest,
365
376
  type CreateStorefrontSiteRequest,
366
377
  type CreateStorefrontVersionRequest,
367
378
  changeStorefrontSlugRequestSchema,
368
379
  changeStorefrontTemplateRequestSchema,
380
+ connectStorefrontGitRequestSchema,
369
381
  createStorefrontEditPreviewLinkRequestSchema,
370
382
  createStorefrontPreviewLinkRequestSchema,
371
383
  createStorefrontSiteRequestSchema,
@@ -374,6 +386,8 @@ import {
374
386
  type DisableStorefrontHostingRequest,
375
387
  deleteStorefrontFileRequestSchema,
376
388
  disableStorefrontHostingRequestSchema,
389
+ type LinkStorefrontGitInstallationRequest,
390
+ linkStorefrontGitInstallationRequestSchema,
377
391
  type MoveStorefrontFileRequest,
378
392
  moveStorefrontFileRequestSchema,
379
393
  type PublishStorefrontVersionRequest,
@@ -385,8 +399,10 @@ import {
385
399
  rollbackStorefrontRequestSchema,
386
400
  runStorefrontCheckRequestSchema,
387
401
  type SearchStorefrontFilesRequest,
402
+ type SetStorefrontDeployApprovalRequest,
388
403
  type SetStorefrontVisibilityRequest,
389
404
  searchStorefrontFilesRequestSchema,
405
+ setStorefrontDeployApprovalRequestSchema,
390
406
  setStorefrontVisibilityRequestSchema,
391
407
  storefrontBuildSchema,
392
408
  storefrontCheckSchema,
@@ -398,6 +414,9 @@ import {
398
414
  storefrontFileMoveResultSchema,
399
415
  storefrontFileSearchResultSchema,
400
416
  storefrontFileWriteResultSchema,
417
+ storefrontGitConnectStateSchema,
418
+ storefrontGitRepositorySchema,
419
+ storefrontGitStatusSchema,
401
420
  storefrontHostingSchema,
402
421
  storefrontPreviewLinkSchema,
403
422
  storefrontSiteDomainSchema,
@@ -421,7 +440,7 @@ import {
421
440
  type WriteStorefrontFilesRequest,
422
441
  writeStorefrontFilesRequestSchema,
423
442
  } from "./schemas/storefront-hosting";
424
- import { publicPlansSchema, storeSubscriptionSchema } from "./schemas/subscription";
443
+ import { storeSubscriptionSchema } from "./schemas/subscription";
425
444
  import {
426
445
  type UploadPurpose,
427
446
  type UploadRequest,
@@ -701,6 +720,27 @@ export function createStoreClient(options: StoreClientOptions) {
701
720
  savedOptionSetFromProductResultSchema,
702
721
  { body: savedOptionSetFromProductRequestSchema.parse(body) },
703
722
  ),
723
+ /** 번역(이슈 #134) — 기본 언어 이름이 키다. 불러올 때 상품 번역으로 복사한다 */
724
+ localizations: {
725
+ list: (optionSetId: string) =>
726
+ storeHttp.request(
727
+ "GET",
728
+ path`/saved-option-sets/${optionSetId}/localizations`,
729
+ z.array(savedOptionSetLocalizationSchema),
730
+ ),
731
+ put: (optionSetId: string, locale: string, body: SavedOptionSetLocalizationRequest) =>
732
+ storeHttp.request(
733
+ "PUT",
734
+ path`/saved-option-sets/${optionSetId}/localizations/${locale}`,
735
+ savedOptionSetLocalizationSchema,
736
+ { body: savedOptionSetLocalizationRequestSchema.parse(body) },
737
+ ),
738
+ remove: (optionSetId: string, locale: string) =>
739
+ storeHttp.requestVoid(
740
+ "DELETE",
741
+ path`/saved-option-sets/${optionSetId}/localizations/${locale}`,
742
+ ),
743
+ },
704
744
  },
705
745
 
706
746
  /**
@@ -737,6 +777,27 @@ export function createStoreClient(options: StoreClientOptions) {
737
777
  }),
738
778
  remove: (infoSectionId: string) =>
739
779
  storeHttp.requestVoid("DELETE", path`/info-sections/${infoSectionId}`),
780
+ /** 번역(이슈 #134) — 언어별 제목·본문 */
781
+ localizations: {
782
+ list: (infoSectionId: string) =>
783
+ storeHttp.request(
784
+ "GET",
785
+ path`/info-sections/${infoSectionId}/localizations`,
786
+ z.array(infoSectionLocalizationSchema),
787
+ ),
788
+ put: (infoSectionId: string, locale: string, body: InfoSectionLocalizationRequest) =>
789
+ storeHttp.request(
790
+ "PUT",
791
+ path`/info-sections/${infoSectionId}/localizations/${locale}`,
792
+ infoSectionLocalizationSchema,
793
+ { body: infoSectionLocalizationRequestSchema.parse(body) },
794
+ ),
795
+ remove: (infoSectionId: string, locale: string) =>
796
+ storeHttp.requestVoid(
797
+ "DELETE",
798
+ path`/info-sections/${infoSectionId}/localizations/${locale}`,
799
+ ),
800
+ },
740
801
  },
741
802
 
742
803
  /**
@@ -786,6 +847,27 @@ export function createStoreClient(options: StoreClientOptions) {
786
847
  storeHttp.request("POST", "/collections/preview", collectionPreviewSchema, {
787
848
  body: collectionPreviewRequestSchema.parse(body),
788
849
  }),
850
+ /** 번역(이슈 #134) — 언어별 제목·설명·SEO */
851
+ localizations: {
852
+ list: (collectionId: string) =>
853
+ storeHttp.request(
854
+ "GET",
855
+ path`/collections/${collectionId}/localizations`,
856
+ z.array(collectionLocalizationSchema),
857
+ ),
858
+ put: (collectionId: string, locale: string, body: CollectionLocalizationRequest) =>
859
+ storeHttp.request(
860
+ "PUT",
861
+ path`/collections/${collectionId}/localizations/${locale}`,
862
+ collectionLocalizationSchema,
863
+ { body: collectionLocalizationRequestSchema.parse(body) },
864
+ ),
865
+ remove: (collectionId: string, locale: string) =>
866
+ storeHttp.requestVoid(
867
+ "DELETE",
868
+ path`/collections/${collectionId}/localizations/${locale}`,
869
+ ),
870
+ },
789
871
  },
790
872
 
791
873
  categories: {
@@ -2255,6 +2337,43 @@ export function createStoreClient(options: StoreClientOptions) {
2255
2337
  storeHttp.request("PUT", "/storefront/site/source", storefrontSourceReplacedSchema, {
2256
2338
  body: replaceStorefrontSourceRequestSchema.parse(body),
2257
2339
  }),
2340
+ /** `PUT /v1/storefront/site/deploy-approval` — 승인 없이 배포(상점 플랫폼 OWNER·ADMIN 전용) */
2341
+ setDeployApproval: (body: SetStorefrontDeployApprovalRequest) =>
2342
+ storeHttp.request("PUT", "/storefront/site/deploy-approval", storefrontSiteSchema, {
2343
+ body: setStorefrontDeployApprovalRequestSchema.parse(body),
2344
+ }),
2345
+ /** `GET /v1/storefront/site/git` — Git 원천(발행 소스를 둔 저장소)과 상태(#138) */
2346
+ getGit: () => storeHttp.request("GET", "/storefront/site/git", storefrontGitStatusSchema),
2347
+ /** `POST /v1/storefront/site/git/installations/state` — GitHub 설치 화면에 싣는 1회용 state(상점 플랫폼 전용) */
2348
+ createGitConnectState: () =>
2349
+ storeHttp.request(
2350
+ "POST",
2351
+ "/storefront/site/git/installations/state",
2352
+ storefrontGitConnectStateSchema,
2353
+ ),
2354
+ /** `POST /v1/storefront/site/git/installations` — GitHub App 설치 확인(상점 플랫폼 전용) → 그 설치의 저장소 */
2355
+ linkGitInstallation: (body: LinkStorefrontGitInstallationRequest) =>
2356
+ storeHttp.request(
2357
+ "POST",
2358
+ "/storefront/site/git/installations",
2359
+ z.array(storefrontGitRepositorySchema),
2360
+ { body: linkStorefrontGitInstallationRequestSchema.parse(body) },
2361
+ ),
2362
+ /** `GET /v1/storefront/site/git/installations/{installationId}/repositories` — 확인한 설치의 저장소(상점 플랫폼 전용) */
2363
+ listGitRepositories: (installationId: number) =>
2364
+ storeHttp.request(
2365
+ "GET",
2366
+ `/storefront/site/git/installations/${encodeURIComponent(String(installationId))}/repositories`,
2367
+ z.array(storefrontGitRepositorySchema),
2368
+ ),
2369
+ /** `POST /v1/storefront/site/git/connect` — 셀러 GitHub 저장소 연결(상점 플랫폼 전용) */
2370
+ connectGit: (body: ConnectStorefrontGitRequest) =>
2371
+ storeHttp.request("POST", "/storefront/site/git/connect", storefrontGitStatusSchema, {
2372
+ body: connectStorefrontGitRequestSchema.parse(body),
2373
+ }),
2374
+ /** `POST /v1/storefront/site/git/disconnect` — 연결 해제(sayren 저장소로 되돌림, 상점 플랫폼 전용) */
2375
+ disconnectGit: () =>
2376
+ storeHttp.request("POST", "/storefront/site/git/disconnect", storefrontGitStatusSchema),
2258
2377
  },
2259
2378
 
2260
2379
  subscription: {
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export * from "./schemas/market";
42
42
  export * from "./schemas/misc";
43
43
  export * from "./schemas/order";
44
44
  export * from "./schemas/payment";
45
+ export * from "./schemas/plans";
45
46
  export * from "./schemas/points";
46
47
  export * from "./schemas/product";
47
48
  export * from "./schemas/product-info";
@@ -587,3 +587,28 @@ export const collectionPreviewSchema = z.object({
587
587
  });
588
588
 
589
589
  export type CollectionPreview = z.infer<typeof collectionPreviewSchema>;
590
+
591
+ /**
592
+ * 컬렉션 번역 (이슈 #134) — 언어 하나의 제목·설명·SEO. 컬렉션 필드는 상점 기본 언어 값이고 번역 레코드가 언어별 값이다.
593
+ * 비운 칸(null)은 스토어프론트가 기본 언어 값으로 대체한다(칸 단위 대체).
594
+ */
595
+ export const collectionLocalizationSchema = z.object({
596
+ locale: z.string().describe("언어(BCP 47)"),
597
+ title: z.string().nullable().describe("제목. null이면 기본 언어 제목"),
598
+ description: z.string().nullable(),
599
+ metaTitle: z.string().nullable(),
600
+ metaDescription: z.string().nullable(),
601
+ updatedAt: z.string(),
602
+ });
603
+
604
+ export type CollectionLocalization = z.infer<typeof collectionLocalizationSchema>;
605
+
606
+ /** `PUT /v1/collections/{collectionId}/localizations/{locale}` 본문 — 레코드 전체를 바꾼다. 빠진 칸은 null(기본 언어 값으로 대체)이다 */
607
+ export const collectionLocalizationRequestSchema = z.strictObject({
608
+ title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).nullable().optional(),
609
+ description: z.string().trim().max(MAX_COLLECTION_DESCRIPTION).nullable().optional(),
610
+ metaTitle: z.string().trim().max(60).nullable().optional(),
611
+ metaDescription: z.string().trim().max(160).nullable().optional(),
612
+ });
613
+
614
+ export type CollectionLocalizationRequest = z.infer<typeof collectionLocalizationRequestSchema>;
@@ -0,0 +1,104 @@
1
+ import { z } from "zod";
2
+ import { responsePlanCodeSchema } from "./subscription";
3
+
4
+ /**
5
+ * 요금제 비교표 — `GET /v1/public/plans`(인증 없음). 요금제마다 가격과 기능 키 값을 준다. 값은 서버가 관리하고(사내 어드민 게시) 언제든
6
+ * 바뀔 수 있으니 코드에 요금제 수치를 두지 않는다. 상점 하나의 구독·사용량은 `subscription.ts`다
7
+ */
8
+
9
+ const limitOrContract = (description: string) =>
10
+ z.number().int().nullable().describe(`${description}. null이면 계약(엔터프라이즈)·한도 없음`);
11
+
12
+ /**
13
+ * 요금제 기능 키 값 — 요금제 비교표·상점 플랫폼 요금제 화면이 쓴다. 키는 서버 판정의 기능 키와 같다(추가만 한다). 상위 요금제는 하위 요금제
14
+ * 기능을 모두 포함한다
15
+ */
16
+ export const planEntitlementsSchema = z
17
+ .object({
18
+ "features.members": z
19
+ .boolean()
20
+ .describe("소유자 외 구성원(관리자·직원)의 초대와 상점 접근. false면 소유자만 쓴다"),
21
+ "features.admin_api": z
22
+ .boolean()
23
+ .describe(
24
+ "관리 API(개인 액세스 토큰·외부 앱 토큰 호출). 상점 플랫폼·CLI·MCP는 모든 요금제에서 쓴다",
25
+ ),
26
+ "features.analytics": z.boolean().describe("애널리틱스 화면·조회 API"),
27
+ "limits.ai_reads_monthly": limitOrContract("MCP 읽기 월 한도"),
28
+ "limits.ai_actions_monthly": limitOrContract(
29
+ "MCP 쓰기(AI 작업) 월 한도 — 주문 처리는 막지 않는다",
30
+ ),
31
+ "limits.custom_domains": limitOrContract("내 도메인 수"),
32
+ "limits.webhooks": limitOrContract("웹훅 구독 수(0이면 쓸 수 없다)"),
33
+ "limits.public_apps": limitOrContract("공개 앱(앱 스토어 게시 앱) 설치 수"),
34
+ "limits.private_apps": limitOrContract(
35
+ "비공개 앱(이 상점 전용 앱) 설치 수(0이면 설치할 수 없다)",
36
+ ),
37
+ "limits.ai_credits_monthly": limitOrContract(
38
+ "AI 상점 도우미 월 크레딧(1 크레딧 = 모델 원가 $0.01, 이월 없음)",
39
+ ),
40
+ "limits.storefront_preview_hours_monthly": limitOrContract("편집 환경 시간(월, 시간)"),
41
+ "limits.storefront_build_minutes_monthly": limitOrContract("빌드 시간(월, 분)"),
42
+ "limits.storefront_requests_monthly": limitOrContract(
43
+ "스토어프론트 월 트래픽(요청 수) — 넘어도 막지 않고 안내만 한다",
44
+ ),
45
+ "limits.admin_api_requests_monthly": limitOrContract(
46
+ "관리 API 월 요청 — 넘으면 속도만 낮춘다(주문 처리 제외)",
47
+ ),
48
+ "rate_limit.admin_api": z
49
+ .number()
50
+ .nullable()
51
+ .describe("관리 API 분당 속도 배수(기본 분당 600 × 배수)"),
52
+ "storefront.cpu_ms": z.number().int().describe("스토어프론트 사이트 요청당 CPU 상한(ms)"),
53
+ "storefront.subrequests": z
54
+ .number()
55
+ .int()
56
+ .describe("스토어프론트 사이트 요청당 하위 요청 상한"),
57
+ "storefront.rate_limit_per_minute": z
58
+ .number()
59
+ .int()
60
+ .describe("스토어프론트 남용 방지 요청 속도 상한(사이트당 분당)"),
61
+ "seats.included": limitOrContract(
62
+ "포함 구성원 석 — 구성원은 모든 요금제에서 무료·무제한이라 null",
63
+ ),
64
+ "retention.analytics_days": z.number().int().describe("애널리틱스 보관 일수"),
65
+ "retention.api_logs_days": limitOrContract("API 로그 보관 일수(0이면 API 로그 없음)"),
66
+ "retention.audit_days": limitOrContract("감사 로그 보관 일수"),
67
+ "support.tier": z
68
+ .union([z.enum(["COMMUNITY", "EMAIL", "EMAIL_PRIORITY", "DEDICATED", "SLA"]), z.string()])
69
+ .describe(
70
+ "지원 등급(`COMMUNITY`·`EMAIL`·`EMAIL_PRIORITY`·`DEDICATED`·`SLA`, 새 등급이 더해질 수 있다)",
71
+ ),
72
+ })
73
+ .describe("요금제 기능 키 값");
74
+ export type PlanEntitlementValues = z.infer<typeof planEntitlementsSchema>;
75
+
76
+ /** 공개 요금제 한 줄 — `GET /v1/public/plans` */
77
+ export const publicPlanSchema = z.object({
78
+ code: responsePlanCodeSchema,
79
+ name: z.string().describe("표시 이름(Free·Pro·프리미엄·엔터프라이즈)"),
80
+ version: z.number().int().describe("요금제 버전 — 값이 바뀌면 새 버전이 게시된다"),
81
+ sort: z.number().int(),
82
+ purchasable: z
83
+ .boolean()
84
+ .describe("셀프 결제로 고를 수 있는가(Free·엔터프라이즈는 아니다 — 엔터프라이즈는 계약)"),
85
+ prices: z.object({
86
+ currency: z.literal("KRW"),
87
+ monthly: z.number().int().nullable().describe("월 가격(공급가, 부가세 별도)"),
88
+ startingAt: z.boolean().describe("「부터」 가격(엔터프라이즈 계약)"),
89
+ vatIncluded: z
90
+ .object({
91
+ monthly: z.number().int().nullable(),
92
+ })
93
+ .describe("표시 가격(부가세 포함) — 요금제 비교표는 이 값을 보인다"),
94
+ }),
95
+ entitlements: planEntitlementsSchema,
96
+ });
97
+ export type PublicPlan = z.infer<typeof publicPlanSchema>;
98
+
99
+ /** 요금제 비교표 — `GET /v1/public/plans`(인증 없음, 캐시 가능). 값은 서버가 관리하고 언제든 바뀔 수 있다 */
100
+ export const publicPlansSchema = z.object({
101
+ vatPercent: z.number().int().describe("부가세율(%)"),
102
+ plans: z.array(publicPlanSchema).describe("공개 요금제의 지금 버전 — 정렬 순"),
103
+ });
104
+ export type PublicPlans = z.infer<typeof publicPlansSchema>;
@@ -188,3 +188,29 @@ export const infoSectionRequestSchema = z.object({
188
188
 
189
189
  export type InfoSectionRequest = z.infer<typeof infoSectionRequestSchema>;
190
190
  export type InfoSectionRequestInput = z.input<typeof infoSectionRequestSchema>;
191
+
192
+ /**
193
+ * 안내 섹션 번역 (이슈 #134) — 언어 하나의 제목·본문. 섹션 필드는 상점 기본 언어 값이다. 비운 칸(null)은 스토어프론트가 기본 언어 값으로 대체한다.
194
+ * 섹션을 연결한 모든 상품이 같은 번역을 보인다.
195
+ */
196
+ export const infoSectionLocalizationSchema = z.object({
197
+ locale: z.string().describe("언어(BCP 47)"),
198
+ title: z.string().nullable(),
199
+ body: z
200
+ .string()
201
+ .nullable()
202
+ .describe("본문 HTML(상세설명과 같은 정화 규칙). null이면 기본 언어 본문"),
203
+ bodyJson: z.string().nullable(),
204
+ updatedAt: z.string(),
205
+ });
206
+
207
+ export type InfoSectionLocalization = z.infer<typeof infoSectionLocalizationSchema>;
208
+
209
+ /** `PUT /v1/info-sections/{infoSectionId}/localizations/{locale}` 본문 — 레코드 전체를 바꾼다. 빠진 칸은 null이다 */
210
+ export const infoSectionLocalizationRequestSchema = z.strictObject({
211
+ title: z.string().trim().min(1).max(MAX_INFO_SECTION_TITLE).nullable().optional(),
212
+ body: z.string().nullable().optional(),
213
+ bodyJson: z.string().nullable().optional(),
214
+ });
215
+
216
+ export type InfoSectionLocalizationRequest = z.infer<typeof infoSectionLocalizationRequestSchema>;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_PRODUCT_IMAGES, productImageUrlSchema } from "./product";
3
+ import { MAX_NOTICE_VALUE_LENGTH } from "./product-info";
3
4
 
4
5
  /**
5
6
  * 상품 번역 (이슈 #134) — 언어 하나의 상품명·상세설명·이미지·옵션 이름. 상품 필드는 상점 기본 언어 값이고 번역 레코드가 언어별 값이다.
@@ -24,8 +25,14 @@ const translatedCustomInputSchema = z.object({
24
25
  placeholder: z.string().nullable().describe("입력 안내. null이면 기본 언어 안내"),
25
26
  });
26
27
 
28
+ const translatedNoticeValueSchema = z.object({
29
+ key: z.string().describe("고시 칸 키"),
30
+ label: z.string().describe("칸 이름(원문)"),
31
+ value: z.string().nullable().describe("셀러 값 번역. null이면 기본 언어 값"),
32
+ });
33
+
27
34
  /**
28
- * 조회 응답 — 지금 상품의 옵션명·값·직접 입력 항목을 모두 싣고 번역하지 않은 칸은 null이다.
35
+ * 조회 응답 — 지금 상품의 옵션명·값·직접 입력 항목, 셀러가 적은 고시 칸을 모두 싣고 번역하지 않은 칸은 null이다.
29
36
  * `optionGroups`는 조합 옵션, `addonGroups`는 추가 선택이다(상품 응답과 같은 구분)
30
37
  */
31
38
  export const productLocalizationSchema = z.object({
@@ -37,6 +44,13 @@ export const productLocalizationSchema = z.object({
37
44
  optionGroups: z.array(translatedOptionGroupSchema),
38
45
  addonGroups: z.array(translatedOptionGroupSchema),
39
46
  customInputs: z.array(translatedCustomInputSchema),
47
+ notice: z
48
+ .array(translatedNoticeValueSchema)
49
+ .optional()
50
+ .default([])
51
+ .describe(
52
+ "셀러가 적은 상품정보제공 고시 칸의 값 번역(이슈 #134). 출처·법정 문구 칸은 싣지 않는다",
53
+ ),
40
54
  updatedAt: z.string(),
41
55
  });
42
56
 
@@ -71,6 +85,37 @@ export const productLocalizationRequestSchema = z.strictObject({
71
85
  }),
72
86
  )
73
87
  .optional(),
88
+ notice: z
89
+ .array(
90
+ z.strictObject({
91
+ key: z.string(),
92
+ value: z.string().trim().min(1).max(MAX_NOTICE_VALUE_LENGTH).nullable().optional(),
93
+ }),
94
+ )
95
+ .optional()
96
+ .describe(
97
+ "고시 칸 값 번역. 상품에 셀러 값이 없는 칸 키는 400 `PRODUCT_LOCALIZATION_TARGET_NOT_FOUND`",
98
+ ),
74
99
  });
75
100
 
76
101
  export type ProductLocalizationRequest = z.infer<typeof productLocalizationRequestSchema>;
102
+
103
+ /**
104
+ * 번역할 칸이 비었는가(이슈 #134, 2026-10-18 결정: 완성도 대신 「번역 필요」 표시만) — 상품명, 기본 언어에 본문이 있는 상세설명,
105
+ * 옵션명·값·직접 입력 항목명, 셀러가 적은 고시 칸. 이미지·입력 안내는 기본 언어 값을 그대로 써도 되는 칸이라 세지 않는다.
106
+ * 상점 플랫폼(상품 수정·목록)과 관리 목록 응답이 같은 함수다
107
+ */
108
+ export function productLocalizationNeedsTranslation(
109
+ localization: Pick<
110
+ ProductLocalization,
111
+ "name" | "description" | "optionGroups" | "addonGroups" | "customInputs"
112
+ > & { notice?: ProductLocalization["notice"] },
113
+ base: { description?: string | null },
114
+ ): boolean {
115
+ if (!localization.name) return true;
116
+ if ((base.description ?? "").trim() && !localization.description) return true;
117
+ const groups = [...localization.optionGroups, ...localization.addonGroups];
118
+ if (groups.some((group) => !group.name || group.values.some((value) => !value.name))) return true;
119
+ if (localization.customInputs.some((item) => !item.label)) return true;
120
+ return (localization.notice ?? []).some((entry) => !entry.value);
121
+ }
@@ -67,6 +67,18 @@ export const productSummarySchema = z.object({
67
67
  .describe(
68
68
  "상점을 만들 때 넣은 샘플 상품인가(이슈 #70). 상점 플랫폼이 「샘플」 배지를 단다. 스토어프론트에는 싣지 않는다. 서버는 항상 싣는다",
69
69
  ),
70
+ localizations: z
71
+ .array(
72
+ z.object({
73
+ locale: z.string().describe("언어(BCP 47)"),
74
+ needsTranslation: z
75
+ .boolean()
76
+ .describe("번역할 칸이 비었는가(`productLocalizationNeedsTranslation`과 같은 규칙)"),
77
+ }),
78
+ )
79
+ .optional()
80
+ .default([])
81
+ .describe("번역 레코드가 있는 언어(이슈 #134). 상점 기본 언어는 싣지 않는다"),
70
82
  });
71
83
 
72
84
  export type ProductSummary = z.infer<typeof productSummarySchema>;
@@ -372,7 +384,7 @@ export const discountSchema = z.object({
372
384
 
373
385
  export type Discount = z.infer<typeof discountSchema>;
374
386
 
375
- export const productSchema = productSummarySchema.extend({
387
+ export const productSchema = productSummarySchema.omit({ localizations: true }).extend({
376
388
  prices: z
377
389
  .array(storedProductCurrencyPriceSchema)
378
390
  .optional()
@@ -174,3 +174,65 @@ export const savedOptionSetListQuerySchema = z.object({
174
174
  sort: savedOptionSetSortSchema.optional().describe("정렬 (기본 updatedAt.desc)"),
175
175
  ...pageQueryShape({ defaultSize: 20, maxSize: 200 }),
176
176
  });
177
+
178
+ /**
179
+ * 저장 옵션 번역 (이슈 #134) — 저장 옵션에는 id가 없어 **기본 언어 이름이 키**다(`baseName`·`baseLabel`).
180
+ * 상품이 불러올 때 상품 번역으로 함께 복사하고(상품 저장 트랜잭션), 그 뒤로는 상품 번역이 원천이다 — 저장 옵션 번역을 고쳐도
181
+ * 이미 불러온 상품은 그대로다. 기본 언어 이름을 바꾸면 그 이름의 번역은 읽지 않는다.
182
+ */
183
+ export const savedOptionSetLocalizationSchema = z.object({
184
+ locale: z.string().describe("언어(BCP 47)"),
185
+ groups: z.array(
186
+ z.object({
187
+ baseName: z.string().describe("기본 언어 옵션명"),
188
+ name: z.string().nullable().describe("번역 옵션명. null이면 기본 언어 이름"),
189
+ values: z.array(
190
+ z.object({
191
+ baseName: z.string(),
192
+ name: z.string().nullable(),
193
+ }),
194
+ ),
195
+ }),
196
+ ),
197
+ customInputs: z.array(
198
+ z.object({
199
+ baseLabel: z.string().describe("기본 언어 항목명"),
200
+ label: z.string().nullable(),
201
+ placeholder: z.string().nullable(),
202
+ }),
203
+ ),
204
+ updatedAt: z.string(),
205
+ });
206
+
207
+ export type SavedOptionSetLocalization = z.infer<typeof savedOptionSetLocalizationSchema>;
208
+
209
+ /**
210
+ * `PUT /v1/saved-option-sets/{optionSetId}/localizations/{locale}` 본문 — 레코드 전체를 바꾼다. 번역할 이름만 보낸다.
211
+ * 저장 옵션에 없는 기본 언어 이름은 400 `OPTION_SET_LOCALIZATION_TARGET_NOT_FOUND`, 번역 이름 중복은 상품과 같은 규칙으로 400 `INVALID_OPTION_MODEL`이다
212
+ */
213
+ export const savedOptionSetLocalizationRequestSchema = z.strictObject({
214
+ groups: z
215
+ .array(
216
+ z.strictObject({
217
+ baseName: z.string(),
218
+ name: nameSchema.nullable().optional(),
219
+ values: z
220
+ .array(z.strictObject({ baseName: z.string(), name: nameSchema.nullable().optional() }))
221
+ .optional(),
222
+ }),
223
+ )
224
+ .optional(),
225
+ customInputs: z
226
+ .array(
227
+ z.strictObject({
228
+ baseLabel: z.string(),
229
+ label: z.string().trim().min(1).max(30).nullable().optional(),
230
+ placeholder: z.string().trim().min(1).max(100).nullable().optional(),
231
+ }),
232
+ )
233
+ .optional(),
234
+ });
235
+
236
+ export type SavedOptionSetLocalizationRequest = z.infer<
237
+ typeof savedOptionSetLocalizationRequestSchema
238
+ >;