@zalkera/client 0.7.0 → 0.8.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/llms.txt CHANGED
@@ -14,9 +14,12 @@
14
14
  ## 1. 설치·초기화
15
15
 
16
16
  ```bash
17
- npm i ./zalkera-client-0.6.0.tgz # 배포 tarball(심볼릭 링크 말고 tarball — Turbopack 이슈)
17
+ npm install @zalkera/client # 공개 npm (MIT)
18
18
  ```
19
19
 
20
+ - 버전 못 박기: tarball 을 `vendor/` 에 넣어 써도 결과는 동일하다.
21
+ - ❌ 디렉터리 심링크(`file:<dir>`) 금지 — Next 16 Turbopack 이 프로젝트 밖을 못 읽어 깨진다.
22
+
20
23
  ```ts
21
24
  // lib/zalkera.ts — 서버 전용 싱글턴
22
25
  import { createZalkeraClient } from "@zalkera/client";
@@ -31,6 +34,8 @@ export const zalkera = createZalkeraClient({
31
34
  - `.env`: `ZALKERA_API_BASE`, `ZALKERA_TENANT`, (선택)`ZALKERA_STOREFRONT_KEY`. **절대 클라이언트 컴포넌트에서 import 하지 말 것** — baseUrl·시크릿 노출.
32
35
  - `secretKey`는 진짜 비밀이다 — `NEXT_PUBLIC_*` 접두사·클라이언트 번들 금지. 서버 `.env`에만. 안 주면 종전대로 `tenant`(X-Tenant)만으로 동작(하위호환).
33
36
  - 모든 메서드는 성공 시 데이터를, 실패 시 `ZalkeraError`(`.status`, `.code`, `.isRateLimited`, `.isStorefrontKeyError`, `.validationErrors`)를 던진다.
37
+ - **경로 파라미터는 라이브러리가 인코딩한다** — slug·주문번호·id 를 넘기기 전에 `encodeURIComponent` 를 직접 씌우지 마라(이중 인코딩된다). 쿼리 파라미터도 마찬가지다.
38
+ - **`baseUrl` 은 최종 오리진이어야 한다** — 클라이언트는 리다이렉트를 따라가지 않는다(따라가면 `X-Storefront-Key` 가 다른 오리진으로 샌다). 백엔드가 3xx 를 주면 502 `UPSTREAM_REDIRECT`.
34
39
 
35
40
  ## 2. 왜 서버 사이드인가 (중요)
36
41
 
@@ -61,9 +66,15 @@ await zalkera.submitInquiry(input, { clientIp: ip });
61
66
 
62
67
  ### 커머스 — 카탈로그(공개)
63
68
  - `listProducts({productType?, keyword?, page?, size?, sort?})` → `Paginated<ProductSummary>` (카드용: slug·name·priceFrom·inStock)
64
- - `getProduct(slug)` → `ProductDetail { name, productType, variants[] }`
69
+ - `getProduct(slug)` → `ProductDetail { id, slug, name, description, productType, coverAssetId, seo, variants[] }`
70
+ - `seo` 는 **JSON 문자열 패스스루**(백엔드가 검증 안 함·미설정이면 null). 권장 구조는 `{"title","description"}` —
71
+ `SiteConfig.seoDefaults` 와 같은 모양이다. **파싱 실패해도 죽으면 안 된다**(`parseConfig` 로 읽고, 없으면 상품명으로 강하).
65
72
  - `listProductCategories()` → `ProductCategory[]`
66
73
  - **variant 가 판매 단위다.** 가격·재고·장바구니·주문은 전부 `variant.id` 기준. 단순 상품도 variant 1개.
74
+ `ProductVariant { id, sku, optionSignature, price, compareAtPrice, currency, inStock, available }`
75
+ - `optionSignature` 는 옵션 조합 표시용 문자열("레드/XL") — 단순 상품은 **빈 문자열**이라 그때는 안 그린다.
76
+ - `compareAtPrice` 는 정가(취소선용). null 이면 할인 아님 — **0 과 혼동하지 마라**.
77
+ - 금액 표기는 `currency` 를 따른다. 하드코딩하지 마라.
67
78
  - `variant.inStock`(불리언)·`variant.available`(추적 재고 수량, 무한재고면 null)로 재고 표시.
68
79
  - `productType`: `PHYSICAL`(배송)·`DIGITAL`(즉시)·`SERVICE`(예약).
69
80
  **`SERVICE` 는 장바구니·체크아웃을 타지 않는다** — 별도 예약 흐름(§4.6). 담기 시도는 400 이다.
@@ -108,11 +119,65 @@ await zalkera.submitInquiry(input, { clientIp: ip });
108
119
  ### 커머스 — 결제·주문·배송
109
120
  - `checkout(input, session)` → `OrderDetail` (input: `{ buyerName, buyerPhone, buyerEmail?, shipTo? }`)
110
121
  - 주문 생성 시 가격 스냅샷 동결 + 재고 원자적 차감. 재고 부족이면 409. 상태 `PENDING_PAYMENT`.
111
- - `startPayment(orderNo, access)` → `{ paymentUrl }` — 고객을 paymentUrl 로 리다이렉트.
122
+ - `startPayment(orderNo, access)` → `PaymentSession { paymentUrl, pgPaymentId, widget? }`
123
+ - **`widget` 유무가 분기다**: 있으면 위젯형(내 사이트에서 결제창), 없으면 `paymentUrl` 로 리다이렉트(§4.3).
124
+ - `widget` 에는 **브라우저 노출이 전제된 값만** 담긴다(토스: `vendor:"TOSS_PAYMENTS"`·`clientKey`·`orderId`·`amount`·`orderName`). PG secretKey 는 안 들어온다.
125
+ - **키 구성은 벤더마다 다르다** — `widget.vendor` 로 분기해 읽는다.
126
+ - `confirmPayment(orderNo, providerParams, access)` → `void` — **위젯형 전용** 승인 확정.
127
+ 성공 콜백 파라미터(`{ paymentKey }` 등)를 그대로 넘긴다. 금액은 안 넘겨도 된다 —
128
+ 서버가 **저장된 주문 금액**으로 PG 에 직접 묻는다(브라우저 금액 조작 무효).
112
129
  - `getOrder(orderNo, access)` · `listMyOrders(accessToken,{page,size})` · `cancelOrder(orderNo, access)`
113
130
  · `completeOrder(orderNo, access)` · `getShipment(orderNo, access)`
114
131
  - `access = { accessToken? }`(로그인) **또는 `{ phone? }`(게스트, 주문 시 남긴 연락처)**.
115
132
 
133
+ ### 계약 헬퍼 — 직접 짜지 말고 이걸 부른다
134
+
135
+ 이 패키지는 **API 클라이언트이면서 계약 부품의 운반체**다. 아래는 전부 `@zalkera/client` 에서 import 한다 —
136
+ 프로젝트 안에 `lib/safeUrl.ts`·`sections/parse.ts` 를 새로 만들지 마라(사본이 갈라지면 수리가 안 퍼진다).
137
+
138
+ - `safeLinkUrl(raw)` → `string` — **모든 `href` 는 이걸 태운다.** 콘솔 입력이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
139
+ - `parseConfig<T>(config)` → `T | null` — 섹션 `config` JSON 파싱. **절대 throw 하지 않는다**(실패 시 null).
140
+ - `asString(v)` / `asId(v)` — 형이 안 맞으면 `undefined`.
141
+ - `asIdArray(v)` / `asObjectArray(v)` — **빈 배열**로 강하한다(undefined 아님).
142
+ 빈 배열은 truthy 라 `if (items)` 로 결측을 판별하면 안 된다 — `items.length` 을 봐라.
143
+ - 위 가드로 **필수 값이 없으면 그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
144
+ - `mediaSrc(assetId)` → `/media/{id}` **경로 문자열**. 이미지는 전부 이걸 통한다.
145
+ 전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(스타터에 있다).
146
+ 이 헬퍼는 경로만 만든다 — 라우트를 안 만들면 이미지가 404 다.
147
+ - `SECTION_CONTRACT` / `SECTION_CONTRACT_REV` / `sectionsOfVertical(vertical)` — 아는 섹션 어휘의 코드 표현(§9).
148
+ 타입·키를 **지어내지 말고** 여기서 확인한다.
149
+ - `parseThemeColors(raw)` → `ParsedTheme` — 테마 토큰 파서(§8). 화이트리스트라 임의 CSS 가 안 들어온다.
150
+
151
+ ### ISR 캐시 태그 (`ReadOptions`)
152
+
153
+ 읽기 메서드는 마지막 인자로 `{ tags: [...] }` 를 받는다. 넘기면 그 fetch 에 `next.tags` 가 실려
154
+ **백엔드가 `revalidateTag` 로 온디맨드 무효화**한다 — 테넌트가 콘솔에서 고친 것이 즉시 반영되는 경로다.
155
+
156
+ ```ts
157
+ const config = await zalkera.getSiteConfig({ tags: ["site-config"] });
158
+ const product = await zalkera.getProduct(slug, { tags: ["products", `product:${slug}`] });
159
+ ```
160
+
161
+ **백엔드가 실제로 발화하는 태그는 둘뿐이다**(실측 — `OperationRegistryChangeApplier`):
162
+
163
+ | 콘솔에서 바뀐 것 | 발화 태그 |
164
+ |---|---|
165
+ | 테마·레이아웃·회사정보(`site.*`) | `site-config` |
166
+ | 상품·가격·재고(`product.*`·`inventory.*`) | `products` |
167
+ | 그 밖(예약·게시물·미지의 op) | 태그 없이 `"/"` 경로 무효화로 폴백 |
168
+
169
+ - **`product:{slug}` 는 기대하지 마라.** 백엔드가 일부러 안 붙인다 — 오퍼레이션 페이로드의 상품 참조가
170
+ 고객이 말로 지시한 모호 참조라 slug 와 일치한다는 보장이 없다. 상품 페이지도 `products` 로 받는다.
171
+ - **모든 페이지 fetch 에 `site-config` 를 동승시켜라.** 테마·레이아웃은 전 페이지에 영향인데,
172
+ 발화 태그는 `site-config` 하나뿐이라 이걸 안 달면 그 페이지만 옛 테마로 남는다.
173
+
174
+ ```ts
175
+ // 페이지 라우트 — 자기 태그 + site-config 동승
176
+ const page = await zalkera.getPage(slug, { tags: ["site-config", "pages", `page:${slug}`] });
177
+ ```
178
+
179
+ - **안 넘기면 세그먼트 기본 캐시만 걸린다** — 콘솔에서 고쳐도 화면이 안 바뀐다는 신고가 대개 이것이다.
180
+
116
181
  ## 4. 레시피
117
182
 
118
183
  ### 4.1 상품 목록 페이지 (RSC)
@@ -154,11 +219,11 @@ export async function POST(req: Request) {
154
219
  // → 스타터의 checkout/page.tsx(openWidget)·payment/complete/page.tsx 를 그대로 쓴다
155
220
  await zalkera.confirmPayment(orderNo, { paymentKey }, access); // BFF 경유
156
221
  } else {
157
- redirect(session.paymentUrl); // 리다이렉트형(PayOneQ )
222
+ redirect(session.paymentUrl); // 리다이렉트형(PG 결제창으로 이동)
158
223
  }
159
224
  ```
160
225
  `widget` 유무로만 분기한다 — **벤더 이름으로 분기하지 마라**(벤더가 늘어난다).
161
- 3. 페이원큐 결제창에서 결제. **결과는 서버 웹훅으로 백엔드가 처리**(프론트는 관여 안 함).
226
+ 3. PG 결제창에서 결제. **결과는 서버 웹훅으로 백엔드가 처리**(프론트는 관여 안 함).
162
227
  4. returnUrl 로 돌아오면 `getOrder(orderNo, access)` 로 상태 확인(잠시 PENDING 일 수 있음 → PAID 폴링/새로고침).
163
228
 
164
229
  > ⚠️ returnUrl·successUrl 콜백의 "성공"을 **신뢰하지 말 것**. 진짜 확정은 백엔드가 한다 — 웹훅(리다이렉트형)
@@ -283,7 +348,7 @@ if (booking.orderNo) { // status=PENDING
283
348
  `export const dynamic='force-dynamic'`/`fetch(...,{cache:'no-store'})` **금지**(per-page SSR 유발).
284
349
  실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
285
350
  (장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
286
- `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// oneque-allow-dynamic:
351
+ `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// zalkera-allow-dynamic:
287
352
  <이유>`)이 필요하다 — 서버 동적 렌더는 상시 런타임 원가라 CI validator(§5)가 SEO 라우트에서 강제 차단한다.
288
353
 
289
354
  ## 5.1 산출물 규범 — 발견되는 사이트 (필수)
@@ -343,6 +408,13 @@ catch (e) {
343
408
  - `STOREFRONT_KEY_REQUIRED`(401) — 백엔드 `required` 인데 키 없음/무효 → `secretKey` 옵션 설정(콘솔 발급→서버 `.env`).
344
409
  - `TENANT_MISMATCH`(403) — `secretKey` 가 `tenant` 와 다른 테넌트의 키 → 두 값 정합 확인.
345
410
  - 이 두 코드는 SDK 가 안내 메시지를 매핑해 둔다(`e.message` 그대로 로그에 유용).
411
+ - **SDK 가 직접 만드는 코드(백엔드 `ErrorCode` 가 아니다)** — 전부 502이고 **상류·배선 문제**라, 재시도로 풀리지 않는다:
412
+ - `UPSTREAM_REDIRECT` — 백엔드가 3xx 로 응답했다. **클라이언트는 리다이렉트를 따라가지 않는다**(따라가면
413
+ `X-Storefront-Key`·`X-Tenant`·`X-Cart-Session` 이 제3자 오리진에 그대로 전달된다). 원인은 대개 `baseUrl`
414
+ 오배선이다 — http↔https 승격, www 유무, 프록시의 경로 리라이트를 보고 **`baseUrl` 을 최종 오리진으로** 고쳐라.
415
+ 이 코드에는 우회 옵션이 없다(보안 불변식이라 스위치를 두지 않는다).
416
+ - `UPSTREAM_NON_JSON` — 2xx 인데 본문이 envelope 가 아니다(게이트웨이 HTML 등).
417
+ - `UPSTREAM_UNAVAILABLE` — 502·503·504 에 비JSON 본문.
346
418
  - `e.code` = 백엔드 `ErrorCode` enum 이름. 엔드포인트별 발생 코드 목록의 정본은 **OpenAPI**다.
347
419
  - 코드 이름은 **전역 유일이 아니다**(ORDER_NOT_FOUND 가 여러 도메인에 있다) — 분기는 그 엔드포인트 문맥에서.
348
420
  - 코드를 안 싣는 구버전 백엔드에서는 `e.code` 가 HTTP 사유구("Conflict")로 **폴백**한다. 폴백 값에
@@ -350,9 +422,12 @@ catch (e) {
350
422
 
351
423
  ## 7. 결제 파트너
352
424
 
353
- 결제·정산은 **페이원큐(PayOneQ)** 실행한다. 프론트는 카드정보를 절대 다루지 않는다(PCI 범위 밖).
425
+ 결제·정산은 **제3자 PG(결제대행사)** 위임한다. 프론트는 카드정보를 절대 다루지 않는다(PCI 범위 밖).
354
426
  `startPayment` 가 준 URL 로 보내고, 결과는 백엔드가 웹훅으로 받아 주문 상태를 바꾼다.
355
427
 
428
+ **어느 PG 인지에 의존하는 분기를 짜지 마라** — PG 는 교체될 수 있다. 프론트가 보는 계약(`startPayment` 가
429
+ 주는 리다이렉트 URL, 웹훅 뒤에 바뀌는 주문 상태)은 그대로다.
430
+
356
431
  ## 8. 스타일 규약 — Tailwind v4 + 테마 토큰 (필수)
357
432
 
358
433
  스타터(템플릿)는 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
@@ -440,23 +515,29 @@ CI validator **S6** 가 좌변 잔존을 error 로 막는다.
440
515
 
441
516
  ```ts
442
517
  const page = await zalkera.getPage("home");
443
- page.sections // [{ id, type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" }, …]
518
+ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" }, …]
444
519
  ```
445
520
 
446
521
  **왜 이 구조인가**: 콘텐츠가 마크업에 박히지 않고 계약을 타므로, 테넌트가 콘솔에서·"말로" 고친 것이 재코딩 없이
447
522
  반영된다("FAQ 에 배송 질문 추가해줘"). **마크업에 문구를 하드코딩하면 이 경로가 죽는다.**
448
523
 
449
524
  **규약**
450
- - `config` 는 **문자열**이다. 파싱은 반드시 실패 허용으로(스타터의 `components/sections/parse.ts` 본보기 —
451
- 절대 throw 하지 않는다). 필수 필드가 없으면 **그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
525
+ - `config` 는 **문자열**이다. 파싱은 `parseConfig` + `asString`/`asId`/`asIdArray`/`asObjectArray` 한다
526
+ (이 패키지가 export — 절대 throw 하지 않는다). 필수 필드가 없으면 **그 섹션만 안 그린다**.
527
+ 페이지 전체가 죽으면 안 된다.
452
528
  - **미지 타입은 조용히 건너뛴다.** 어휘는 append-only 라 백엔드가 타입을 늘려도 옛 사이트가 깨지지 않아야 한다.
453
529
  `default:` 에서 에러·경고를 내지 마라 — 그게 계약이다.
454
- - 이미지는 `assetId`(숫자)다. `getMediaUrl(id)`(스타터의 `mediaSrc()`)로 프록시 경유해 그린다.
455
- - **모든 href 는 `lib/safeUrl` 을 태운다.** 콘솔 입력이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
530
+ - 이미지는 `assetId`(숫자)다. `mediaSrc(id)`( 패키지 export)로 프록시 경유해 그린다.
531
+ - **모든 href 는 `safeLinkUrl()` 을 태운다.** 콘솔 입력이라 `javascript:` 가 들어올 수 있다(저장형 XSS).
456
532
  - 자유 HTML 은 없다 — plain text + 줄바꿈뿐(`whitespace-pre-wrap`).
457
533
  - 타입·키를 **지어내지 마라.** 계약 정본은 `SECTION_CONTRACT`(이 패키지가 export)이고, 필요한 어휘가 없으면
458
534
  만들지 말고 **보고**한다.
459
535
 
536
+ **디스패치는 직접 짠다** — `type` 으로 컴포넌트를 고르는 `switch` 하나면 된다. 위 규약에 더해 둘을 지켜라:
537
+
538
+ - **`sortOrder` 로 한 번 더 정렬한다.** 서버가 정렬해 주지만 순서가 틀리면 화면에서 바로 티가 나는 종류다.
539
+ - **key 는 `type`+`sortOrder`+index 로 짠다.** `PageSection` 계약에 id 가 없어(실측) 순수 인덱스보다 낫다.
540
+
460
541
  **어휘 (12종)** — `vertical` 은 콘솔 픽커 그룹핑용이지 사용 제한이 아니다(GENERAL 은 뷰티 테넌트도 쓴다).
461
542
 
462
543
  | type | vertical | config (필수는 굵게) | JSON-LD |
package/package.json CHANGED
@@ -1,50 +1,51 @@
1
1
  {
2
- "name": "@zalkera/client",
3
- "version": "0.7.0",
4
- "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
- "license": "MIT",
6
- "author": "Credium Co., Ltd.",
7
- "type": "module",
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "keywords": [
12
- "zalkera",
13
- "headless",
14
- "commerce",
15
- "cms",
16
- "api-client",
17
- "sdk"
18
- ],
19
- "homepage": "https://zalkera.com",
20
- "files": [
21
- "dist",
22
- "llms.txt"
23
- ],
24
- "main": "./dist/index.cjs",
25
- "module": "./dist/index.js",
26
- "types": "./dist/index.d.ts",
27
- "exports": {
28
- ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js",
31
- "require": "./dist/index.cjs"
2
+ "name": "@zalkera/client",
3
+ "version": "0.8.0",
4
+ "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
+ "license": "MIT",
6
+ "author": "Credium Co., Ltd.",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "keywords": [
12
+ "zalkera",
13
+ "headless",
14
+ "commerce",
15
+ "cms",
16
+ "api-client",
17
+ "sdk"
18
+ ],
19
+ "homepage": "https://zalkera.com",
20
+ "files": [
21
+ "dist",
22
+ "llms.txt"
23
+ ],
24
+ "main": "./dist/index.cjs",
25
+ "module": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "require": "./dist/index.cjs"
32
+ }
33
+ },
34
+ "scripts": {
35
+ "build": "tsup",
36
+ "dev": "tsup --watch",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.10.2",
47
+ "tsup": "^8.3.5",
48
+ "typescript": "^5.7.2",
49
+ "vitest": "^2.1.8"
32
50
  }
33
- },
34
- "scripts": {
35
- "build": "tsup",
36
- "dev": "tsup --watch",
37
- "typecheck": "tsc --noEmit",
38
- "test": "vitest run",
39
- "test:watch": "vitest",
40
- "prepublishOnly": "npm run typecheck && npm test && npm run build"
41
- },
42
- "engines": {
43
- "node": ">=18"
44
- },
45
- "devDependencies": {
46
- "tsup": "^8.3.5",
47
- "typescript": "^5.7.2",
48
- "vitest": "^2.1.8"
49
- }
50
51
  }