@zalkera/client 0.22.3 → 0.23.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.cjs CHANGED
@@ -76,14 +76,38 @@ function isApiErrorBody(value) {
76
76
  }
77
77
 
78
78
  // src/pathSegment.ts
79
+ var UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
79
80
  function seg(v) {
80
- return encodeURIComponent(String(v));
81
+ return encodeURIComponent(String(v).replace(UNPAIRED_SURROGATE, "\uFFFD"));
81
82
  }
82
83
 
83
84
  // src/client.ts
84
85
  var DEFAULT_TIMEOUT_MS = 1e4;
85
86
  function createZalkeraClient(options) {
86
- const baseUrl = options.baseUrl.replace(/\/+$/, "");
87
+ if (typeof options.baseUrl !== "string" || options.baseUrl.trim() === "") {
88
+ throw new Error(
89
+ "@zalkera/client: baseUrl \uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4. ZALKERA_API_BASE \uD658\uACBD\uBCC0\uC218\uB97C \uD655\uC778\uD558\uC138\uC694 (\uC608: https://api.zalkera.com)."
90
+ );
91
+ }
92
+ const baseUrl = options.baseUrl.trim().replace(/\/+$/, "");
93
+ let parsed;
94
+ try {
95
+ parsed = new URL(baseUrl);
96
+ } catch {
97
+ throw new Error(
98
+ `@zalkera/client: baseUrl \uC774 \uC808\uB300 URL \uC774 \uC544\uB2D9\uB2C8\uB2E4: ${baseUrl} \u2014 \uC2A4\uD0B4\uC744 \uD3EC\uD568\uD558\uC138\uC694 (\uC608: https://api.zalkera.com).`
99
+ );
100
+ }
101
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
102
+ throw new Error(
103
+ `@zalkera/client: baseUrl \uC758 \uC2A4\uD0B4\uC774 http/https \uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${baseUrl} (\uC608: https://api.zalkera.com).`
104
+ );
105
+ }
106
+ if (parsed.username !== "" || parsed.password !== "") {
107
+ throw new Error(
108
+ `@zalkera/client: baseUrl \uC5D0 \uC790\uACA9\uC99D\uBA85\uC774 \uBC15\uD600 \uC788\uC2B5\uB2C8\uB2E4 \u2014 \uC624\uB958 \uBB38\uBA74\xB7\uB85C\uADF8\uC5D0 \uADF8\uB300\uB85C \uC2E4\uB9BD\uB2C8\uB2E4. \uD5E4\uB354\uB85C \uB118\uAE30\uC138\uC694.`
109
+ );
110
+ }
87
111
  const fetchImpl = options.fetch ?? globalThis.fetch;
88
112
  if (typeof fetchImpl !== "function") {
89
113
  throw new Error("@zalkera/client: \uC804\uC5ED fetch \uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. Node 18+ \uB97C \uC4F0\uAC70\uB098 options.fetch \uB97C \uC8FC\uC785\uD558\uC138\uC694.");
@@ -164,18 +188,18 @@ function createZalkeraClient(options) {
164
188
  );
165
189
  }
166
190
  const text = await response.text();
167
- const parsed = text ? safeJsonParse(text) : null;
191
+ const parsed2 = text ? safeJsonParse(text) : null;
168
192
  if (!response.ok) {
169
- throw ZalkeraError.fromBody(response.status, parsed);
193
+ throw ZalkeraError.fromBody(response.status, parsed2);
170
194
  }
171
195
  if (!text) return void 0;
172
- if (parsed === null || typeof parsed !== "object") {
196
+ if (parsed2 === null || typeof parsed2 !== "object") {
173
197
  throw new ZalkeraError(
174
198
  `\uC11C\uBC84 \uC751\uB2F5 \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694. (HTTP ${response.status})`,
175
199
  { status: 502, code: "UPSTREAM_NON_JSON" }
176
200
  );
177
201
  }
178
- return parsed.data;
202
+ return parsed2.data;
179
203
  }
180
204
  return {
181
205
  getSiteConfig: (options2) => request("/public/site-config", nextInit(options2)),
@@ -421,7 +445,12 @@ function asId(value) {
421
445
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
422
446
  }
423
447
  function mediaSrc(assetId) {
424
- const raw = String(assetId);
448
+ let raw;
449
+ try {
450
+ raw = String(assetId);
451
+ } catch {
452
+ return void 0;
453
+ }
425
454
  if (raw === "." || raw === "..") return void 0;
426
455
  return `/media/${seg(raw)}`;
427
456
  }
package/dist/index.d.cts CHANGED
@@ -599,7 +599,7 @@ interface ZalkeraClientOptions {
599
599
  * 런타임 의존성이 없다 — 전역 `fetch` 만 쓴다.
600
600
  *
601
601
  * ```ts
602
- * const cms = createZalkeraClient({ baseUrl: process.env.API_BASE_URL!, tenant: "credium" });
602
+ * const cms = createZalkeraClient({ baseUrl: process.env.ZALKERA_API_BASE!, tenant: "credium" });
603
603
  * const posts = await cms.listPosts({ size: 10, sort: "publishedAt,desc" });
604
604
  * await cms.submitInquiry({ name, email, subject, message });
605
605
  * ```
package/dist/index.d.ts CHANGED
@@ -599,7 +599,7 @@ interface ZalkeraClientOptions {
599
599
  * 런타임 의존성이 없다 — 전역 `fetch` 만 쓴다.
600
600
  *
601
601
  * ```ts
602
- * const cms = createZalkeraClient({ baseUrl: process.env.API_BASE_URL!, tenant: "credium" });
602
+ * const cms = createZalkeraClient({ baseUrl: process.env.ZALKERA_API_BASE!, tenant: "credium" });
603
603
  * const posts = await cms.listPosts({ size: 10, sort: "publishedAt,desc" });
604
604
  * await cms.submitInquiry({ name, email, subject, message });
605
605
  * ```
package/dist/index.js CHANGED
@@ -74,14 +74,38 @@ function isApiErrorBody(value) {
74
74
  }
75
75
 
76
76
  // src/pathSegment.ts
77
+ var UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
77
78
  function seg(v) {
78
- return encodeURIComponent(String(v));
79
+ return encodeURIComponent(String(v).replace(UNPAIRED_SURROGATE, "\uFFFD"));
79
80
  }
80
81
 
81
82
  // src/client.ts
82
83
  var DEFAULT_TIMEOUT_MS = 1e4;
83
84
  function createZalkeraClient(options) {
84
- const baseUrl = options.baseUrl.replace(/\/+$/, "");
85
+ if (typeof options.baseUrl !== "string" || options.baseUrl.trim() === "") {
86
+ throw new Error(
87
+ "@zalkera/client: baseUrl \uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4. ZALKERA_API_BASE \uD658\uACBD\uBCC0\uC218\uB97C \uD655\uC778\uD558\uC138\uC694 (\uC608: https://api.zalkera.com)."
88
+ );
89
+ }
90
+ const baseUrl = options.baseUrl.trim().replace(/\/+$/, "");
91
+ let parsed;
92
+ try {
93
+ parsed = new URL(baseUrl);
94
+ } catch {
95
+ throw new Error(
96
+ `@zalkera/client: baseUrl \uC774 \uC808\uB300 URL \uC774 \uC544\uB2D9\uB2C8\uB2E4: ${baseUrl} \u2014 \uC2A4\uD0B4\uC744 \uD3EC\uD568\uD558\uC138\uC694 (\uC608: https://api.zalkera.com).`
97
+ );
98
+ }
99
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
100
+ throw new Error(
101
+ `@zalkera/client: baseUrl \uC758 \uC2A4\uD0B4\uC774 http/https \uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${baseUrl} (\uC608: https://api.zalkera.com).`
102
+ );
103
+ }
104
+ if (parsed.username !== "" || parsed.password !== "") {
105
+ throw new Error(
106
+ `@zalkera/client: baseUrl \uC5D0 \uC790\uACA9\uC99D\uBA85\uC774 \uBC15\uD600 \uC788\uC2B5\uB2C8\uB2E4 \u2014 \uC624\uB958 \uBB38\uBA74\xB7\uB85C\uADF8\uC5D0 \uADF8\uB300\uB85C \uC2E4\uB9BD\uB2C8\uB2E4. \uD5E4\uB354\uB85C \uB118\uAE30\uC138\uC694.`
107
+ );
108
+ }
85
109
  const fetchImpl = options.fetch ?? globalThis.fetch;
86
110
  if (typeof fetchImpl !== "function") {
87
111
  throw new Error("@zalkera/client: \uC804\uC5ED fetch \uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. Node 18+ \uB97C \uC4F0\uAC70\uB098 options.fetch \uB97C \uC8FC\uC785\uD558\uC138\uC694.");
@@ -162,18 +186,18 @@ function createZalkeraClient(options) {
162
186
  );
163
187
  }
164
188
  const text = await response.text();
165
- const parsed = text ? safeJsonParse(text) : null;
189
+ const parsed2 = text ? safeJsonParse(text) : null;
166
190
  if (!response.ok) {
167
- throw ZalkeraError.fromBody(response.status, parsed);
191
+ throw ZalkeraError.fromBody(response.status, parsed2);
168
192
  }
169
193
  if (!text) return void 0;
170
- if (parsed === null || typeof parsed !== "object") {
194
+ if (parsed2 === null || typeof parsed2 !== "object") {
171
195
  throw new ZalkeraError(
172
196
  `\uC11C\uBC84 \uC751\uB2F5 \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694. (HTTP ${response.status})`,
173
197
  { status: 502, code: "UPSTREAM_NON_JSON" }
174
198
  );
175
199
  }
176
- return parsed.data;
200
+ return parsed2.data;
177
201
  }
178
202
  return {
179
203
  getSiteConfig: (options2) => request("/public/site-config", nextInit(options2)),
@@ -419,7 +443,12 @@ function asId(value) {
419
443
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
420
444
  }
421
445
  function mediaSrc(assetId) {
422
- const raw = String(assetId);
446
+ let raw;
447
+ try {
448
+ raw = String(assetId);
449
+ } catch {
450
+ return void 0;
451
+ }
423
452
  if (raw === "." || raw === "..") return void 0;
424
453
  return `/media/${seg(raw)}`;
425
454
  }
@@ -200,11 +200,29 @@ export async function crawlPages({
200
200
  const url = origin + (path === "/" ? "/" : path);
201
201
  let res;
202
202
  try {
203
- res = await fetch(url, {headers: {"user-agent": userAgent}});
203
+ // **리다이렉트를 따라가지 않는다 보안 불변식이라 스위치를 두지 않는다.**
204
+ // 기본값 `follow` 로 두면 크롤 대상이 3xx 로 아무 호스트나 지목해 이 프로세스가
205
+ // 그리로 GET 을 쏜다. 이 크롤러는 발행 직후 자동 검사로 **우리 네트워크 위치에서**
206
+ // 돌 수 있으므로, 그때 지목되는 곳은 사설대역·링크로컬(169.254.169.254)일 수 있다.
207
+ // 심의가 로컬 리스너 둘로 실증했다: 302 한 번에 내부 서비스가 요청을 받고 그 본문이
208
+ // 크롤 결과에 담겼다.
209
+ //
210
+ // 형제 전송로 `src/client.ts` 는 같은 불변식을 이미 세워 두었다(그쪽 사유는 헤더에
211
+ // 실린 테넌트 시크릿이다). **두 전송로 중 하나에만 서 있던 것**이 이 결함이었다.
212
+ res = await fetch(url, {headers: {"user-agent": userAgent}, redirect: "manual"});
204
213
  } catch (e) {
205
214
  fetchFailures.push(`${url} — ${e.message}`);
206
215
  continue;
207
216
  }
217
+ // `redirect: "manual"` 의 결과 형상은 런타임마다 다르다 — Node/undici 는 3xx 를 그대로 주고,
218
+ // 명세 준수 런타임은 `status` 0 · `type` `"opaqueredirect"` 로 준다. 둘 다 잡는다.
219
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
220
+ // opaque-redirect 는 `status` 0 이고 헤더도 안 보인다 — 그때는 대상을 적을 수 없다.
221
+ const to = res.headers.get("location");
222
+ const where = to === null ? "대상 미상" : `→ ${to}`;
223
+ fetchFailures.push(`${url} — 리다이렉트(따라가지 않았습니다, ${where})`);
224
+ continue;
225
+ }
208
226
  if (!res.ok) {
209
227
  fetchFailures.push(`${url} — HTTP ${res.status}`);
210
228
  continue;
package/llms.txt CHANGED
@@ -4,7 +4,7 @@
4
4
  > 관리자 콘솔·백엔드는 잘커라가 제공한다. 당신(AI)이 만들 것은 **공개 사이트(프론트엔드)** 뿐이다.
5
5
  > 타입 안전한 클라이언트 `@zalkera/client` 를 통해 백엔드를 호출한다.
6
6
 
7
- ## 0. 3줄 요약
7
+ ## 0. 줄 요약
8
8
 
9
9
  1. `@zalkera/client` 의 `createZalkeraClient({ baseUrl, tenant, secretKey? })` 로 클라이언트를 만든다.
10
10
  2. **서버 사이드에서만 호출한다**(RSC·route handler·server action). 브라우저에서 직접 부르지 않는다.
@@ -17,7 +17,11 @@
17
17
  npm install @zalkera/client # 공개 npm (MIT)
18
18
  ```
19
19
 
20
- - 버전 못 박기: tarball 을 `vendor/` 에 넣어 써도 결과는 동일하다.
20
+ - 버전 못 박기: tarball 을 **레포 루트의 `vendor/`** 에 넣어 써라. 런타임 동작은 같고, 우리 배송
21
+ 파일이 검사 대상 안에 들어와도 관문을 세우지 않는다(실측: 진단 델타 0줄).
22
+ ⚠ **`src/vendor/` 에 두지 마라** — 소스 루트 안이라 검사기가 그 안의 `createZalkeraClient` 를
23
+ 이 레포의 싱글턴으로 세어 `W1`(싱글턴 못 찾음) 경고가 **사라진다**. 경고가 사라지는 것은 통과가
24
+ 아니라 판정이 흐려진 것이다.
21
25
  - ❌ 디렉터리 심링크(`file:<dir>`) 금지 — Next 16 Turbopack 이 프로젝트 밖을 못 읽어 깨진다.
22
26
 
23
27
  ```ts
@@ -31,9 +35,18 @@ export const zalkera = createZalkeraClient({
31
35
  });
32
36
  ```
33
37
 
34
- - `.env`: `ZALKERA_API_BASE`, `ZALKERA_TENANT`, (선택)`ZALKERA_STOREFRONT_KEY`. **절대 클라이언트 컴포넌트에서 import 하지 말 것** baseUrl·시크릿 노출.
38
+ - `.env`: `ZALKERA_API_BASE`, `ZALKERA_TENANT`, `ZALKERA_SITE_URL`(공개 절대 주소JSON-LD·
39
+ sitemap·robots 가 쓴다 · §4.10·§5.1), (선택)`ZALKERA_STOREFRONT_KEY`.
40
+ 관리형 서빙(잘커라가 호스팅)이면 **넷 다 플랫폼이 주입**한다 — 앞의 셋은 백엔드가, 시크릿 키는
41
+ 서빙 오케스트레이터가 기동 시 넣는다. 그래서 관리형 테넌트는 `.env` 에 이 값들을 비워 둔다.
42
+ BYO(자체 배포)는 콘솔에서 발급해 직접 넣는다. **절대 클라이언트 컴포넌트에서 import 하지 말 것**
43
+ — baseUrl·시크릿 노출. `NEXT_PUBLIC_` 접두를 붙이지 마라(브라우저 번들에 박힌다).
35
44
  - `secretKey`는 진짜 비밀이다 — `NEXT_PUBLIC_*` 접두사·클라이언트 번들 금지. 서버 `.env`에만. 안 주면 종전대로 `tenant`(X-Tenant)만으로 동작(하위호환).
36
- - 모든 메서드는 성공 시 데이터를, 실패 `ZalkeraError`(`.status`, `.code`, `.isRateLimited`, `.isStorefrontKeyError`, `.validationErrors`)를 던진다.
45
+ - 모든 메서드는 성공 시 데이터를, **API 호출 실패 시** `ZalkeraError`(`.status`, `.code`, `.isRateLimited`, `.isStorefrontKeyError`, `.validationErrors`)를 던진다.
46
+ - ⚠ **배선 오류는 `ZalkeraError` 가 아니다.** `baseUrl` 이 비었거나 절대 URL 이 아니면, 그리고 전역
47
+ `fetch` 가 없으면 `createZalkeraClient()` 가 **그 자리에서** 평범한 `Error` 로 죽는다(메시지가
48
+ 환경변수 이름을 짚는다). `catch (e) { if (e instanceof ZalkeraError) … }` 로만 받는 코드는 이걸
49
+ 통과시키므로, 배선은 잡지 말고 그대로 터뜨려 개발 중에 보이게 두는 편이 낫다.
37
50
  - 429 는 두 갈래다: 문의·리드 폼 남발(IP 축)과 **게스트 주문 인가 실패 누적**(주문번호+연락처로 여는 주문/배송/결제 표면 — 정상 조회는 세지 않는다). 서버 사이드에서는 `OrderAccess.context = { clientIp }` 로 방문자를 선언하라.
38
51
  - **경로 파라미터는 라이브러리가 인코딩한다** — slug·주문번호·id 를 넘기기 전에 `encodeURIComponent` 를 직접 씌우지 마라(이중 인코딩된다). 쿼리 파라미터도 마찬가지다.
39
52
  - **`baseUrl` 은 최종 오리진이어야 한다** — 클라이언트는 리다이렉트를 따라가지 않는다(따라가면 `X-Storefront-Key` 가 다른 오리진으로 샌다). 백엔드가 3xx 를 주면 502 `UPSTREAM_REDIRECT`.
@@ -199,6 +212,10 @@ const tokens = await zalkera.socialLogin({
199
212
  - `getOrder(orderNo, access)` · `listMyOrders(accessToken,{page,size})` · `cancelOrder(orderNo, access)`
200
213
  · `completeOrder(orderNo, access)` · `getShipment(orderNo, access)`
201
214
  - `access = { accessToken? }`(로그인) **또는 `{ phone? }`(게스트, 주문 시 남긴 연락처)**.
215
+ ⚠ 서버에서 부를 때는 **`context: { clientIp }` 를 함께 넣는다** — 빼면 백엔드가 보는 IP 가 방문자가
216
+ 아니라 스토어프론트 서버라 그 사이트 방문자가 전부 한 IP 로 뭉친다(§2 의 I2 축 · 검사기가 경고한다).
217
+ `const clientIp = visitorIp(await headers());` 뒤 `{ phone, context: { clientIp } }` 꼴로 쓴다.
218
+ 아래 §4.3·§4.4·§4.6 의 `access` 가 전부 이 정의를 가리킨다.
202
219
 
203
220
  ### 계약 헬퍼 — 직접 짜지 말고 이걸 부른다
204
221
 
@@ -285,10 +302,19 @@ export default async function Products() {
285
302
  ### 4.1-a 카테고리 페이지 (RSC)
286
303
  ```tsx
287
304
  // app/c/[slug]/page.tsx
288
- const categories = await zalkera.listProductCategories();
289
- const category = categories.find(c => c.slug === slug);
290
- if (!category) notFound(); // 카테고리 부재만 404
291
- const page = await zalkera.listProducts({ categoryId: category.id, size: 24 });
305
+ // §4.2·§4.8 같은 규칙 — `params` 는 Promise 이고 값은 퍼센트 인코딩된 원문이다.
306
+ // 카테고리 slug 한국어면 풀지 않은 값은 어떤 `c.slug` 와도 안 맞아 전량 404 가 된다.
307
+ const routeParam = (raw: string) => { try { return decodeURIComponent(raw); } catch { return raw; } };
308
+
309
+ export default async function CategoryPage({ params }: { params: Promise<{ slug: string }> }) {
310
+ const { slug: rawSlug } = await params;
311
+ const slug = routeParam(rawSlug);
312
+ const categories = await zalkera.listProductCategories();
313
+ const category = categories.find(c => c.slug === slug);
314
+ if (!category) notFound(); // 카테고리 부재만 404 다
315
+ const page = await zalkera.listProducts({ categoryId: category.id, size: 24 });
316
+ return <CategoryView category={category} page={page} />;
317
+ }
292
318
  ```
293
319
  - **좁히는 축은 `categoryId`(숫자)** 다 — slug 가 아니다. slug 로 카테고리를 찾고 그 id 로 상품을 좁힌다.
294
320
  - 없는 `categoryId` 는 **빈 목록**이지 404 가 아니다. 목록 API 는 "그 카테고리가 있느냐"를 답하지 않는다 —
@@ -299,8 +325,19 @@ const page = await zalkera.listProducts({ categoryId: category.id, size: 24 });
299
325
  ### 4.2 상품 상세 + 장바구니 담기
300
326
  ```tsx
301
327
  // app/products/[slug]/page.tsx (RSC) — 조회는 공개
302
- const product = await zalkera.getProduct(params.slug);
303
- // 클라이언트 컴포넌트에서 variant 선택 route handler 담기 POST
328
+ // `params` **Promise 이고**, 주는 값은 **퍼센트 인코딩된 원문**이다(§4.8 과 같은 규칙).
329
+ // 한국어 slug 에서 이것을 풀면 `%25ED%259A%258C…`이중 인코딩돼 조회가 전량 404 가 된다.
330
+ // ASCII slug 는 인코딩이 항등이라 이 결함이 안 보인다 — 한국어 사이트에서는 기본값이다.
331
+ const routeParam = (raw: string) => { try { return decodeURIComponent(raw); } catch { return raw; } };
332
+
333
+ export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
334
+ const { slug: rawSlug } = await params;
335
+ const product = await zalkera.getProduct(routeParam(rawSlug));
336
+ // 클라이언트 컴포넌트에서 variant 선택 → route handler 로 담기 POST
337
+ return <ProductView product={product} />;
338
+ }
339
+ // `generateMetadata` 도 params 를 **따로** 읽으므로 거기서도 같이 풀어야 한다 — 한 곳만 고치면
340
+ // 제목은 나오는데 본문이 404 인 형상이 된다.
304
341
  ```
305
342
  ```ts
306
343
  // app/api/cart/add/route.ts (BFF)
@@ -328,6 +365,8 @@ export async function POST(req: Request) {
328
365
  1. 카트 확인 → `checkout({ buyerName, buyerPhone, shipTo }, session, idempotencyKey)` → `order.orderNo`.
329
366
  2. `startPayment(order.orderNo, access)` → **벤더에 따라 두 갈래**(테넌트가 자기 PG 를 고른다 — 기본 TOSS):
330
367
  ```ts
368
+ // access 는 §3 정의 그대로 — 서버에서 부르므로 clientIp 를 함께 선언한다
369
+ const access = { accessToken, context: { clientIp: visitorIp(await headers()) } };
331
370
  const session = await zalkera.startPayment(orderNo, access);
332
371
  if (session.widget) {
333
372
  // 위젯형(토스) — 내 사이트에서 결제창을 띄우고, 성공 콜백 파라미터를 confirm 으로 넘긴다
@@ -368,9 +407,17 @@ export async function POST(req: Request) {
368
407
 
369
408
  ### 4.4 게스트 주문 조회 (비회원 배송조회)
370
409
  ```ts
371
- // 주문번호 + 연락처로 조회 — 로그인 불필요
372
- const order = await zalkera.getOrder(orderNo, { phone });
373
- const shipment = await zalkera.getShipment(orderNo, { phone }); // 배송 상태·추적 이벤트
410
+ import { visitorIp } from "@zalkera/client";
411
+ import { headers } from "next/headers";
412
+
413
+ // 주문번호 + 연락처로 조회 — 로그인 불필요.
414
+ // ⚠ `context: { clientIp }` 를 **같이 넘긴다.** 서버에서 부르면 백엔드가 보는 IP 는 방문자가 아니라
415
+ // 이 서버라, 빼면 그 사이트 방문자가 전부 한 IP 로 뭉친다(§2 의 I2 축 · 검사기가 경고한다).
416
+ const clientIp = visitorIp(await headers());
417
+ const access = { phone, context: { clientIp } };
418
+
419
+ const order = await zalkera.getOrder(orderNo, access);
420
+ const shipment = await zalkera.getShipment(orderNo, access); // 배송 상태·추적 이벤트
374
421
  ```
375
422
 
376
423
  ### 4.5 소셜 로그인 (카카오 예시)
@@ -408,7 +455,10 @@ const booking = await zalkera.createBooking(accessToken, { slotId, quantity: 1 }
408
455
 
409
456
  // 3) 유료·예약금이면 결제로 — 예약 전용 결제 API 는 없다. 기존 흐름을 그대로 탄다.
410
457
  if (booking.orderNo) { // status=PENDING
411
- const payment = await zalkera.startPayment(booking.orderNo, { accessToken });
458
+ const payment = await zalkera.startPayment(booking.orderNo, {
459
+ accessToken,
460
+ context: { clientIp: visitorIp(await headers()) }, // 서버 호출이므로 방문자를 선언한다(§3)
461
+ });
412
462
  // 이후는 §4.3 과 완전히 동일(widget 유무로 분기).
413
463
  }
414
464
  // 무료 예약이면 orderNo=null 이고 이미 CONFIRMED — 결제 단계가 없다.
@@ -503,6 +553,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
503
553
  if (slug === "home") redirect("/"); // 홈의 정본 주소는 루트다(같은 내용이 두 URL 로 색인되면 손해)
504
554
  return (
505
555
  <main>
556
+ {/* webPageJsonLd·siteUrl 은 §4.10 의 부품이다(같은 파일에 두고 import) */}
506
557
  <JsonLd data={webPageJsonLd({ title: page.title, slug }, siteUrl())} />
507
558
  <h1>{page.title}</h1>
508
559
  <SectionList sections={page.sections} /> {/* 정렬하지 않는다 — 배열 순서가 화면 순서다 */}
@@ -643,6 +694,27 @@ export function organizationJsonLd(config: SiteConfig, siteBase: string, type?:
643
694
  };
644
695
  }
645
696
 
697
+ /**
698
+ * 사이트의 **절대 오리진**. 아래 부품들이 받는 `siteBase` 가 이 값이다 — JSON-LD 의 url 은 절대 URL
699
+ * 이어야 하고, 상대 경로로 내면 크롤러가 자기 오리진 기준으로 해석해 남의 도메인을 가리킨다.
700
+ * 뒤 슬래시는 떼고 둔다(부품들이 `${siteBase}/…` 로 이어 붙인다).
701
+ *
702
+ * env 이름은 **`ZALKERA_SITE_URL`** 이다 — 관리형 서빙에서 플랫폼이 주입하는 값 중 하나다(§5.1).
703
+ * `NEXT_PUBLIC_` 접두를 붙이지 마라: 플랫폼이 그 이름으로는 넣지 않고, 접두가 붙으면 브라우저
704
+ * 번들에도 박힌다.
705
+ *
706
+ * **던지지 않는다.** 미설정이면 로컬 기본값으로 강하한다 — 이 함수는 루트 layout 의
707
+ * `generateMetadata` 에서도 불리므로, 던지면 설정 오류 하나가 **전 라우트 500** 이 된다.
708
+ * 대신 배포 전에 반드시 설정하라. 안 하면 sitemap 이 localhost 를 가리킨다.
709
+ *
710
+ * ⚠ **빈 값은 강하하지 않는다** — `??` 는 `""` 를 통과시킨다. `.env` 에 `ZALKERA_SITE_URL=` 로
711
+ * 비워 두면 JSON-LD 의 url 이 `/about` 같은 **상대 경로**가 되어 §5.1 이 금지하는 형태가 된다.
712
+ * 설정하려면 값까지 넣어라(`zalkera-aeo-check` 가 `RELATIVE_URL` 로 잡는다).
713
+ */
714
+ export function siteUrl(): string {
715
+ return (process.env.ZALKERA_SITE_URL ?? "http://localhost:3000").replace(/\/+$/, "");
716
+ }
717
+
646
718
  /** 블로그·공지 상세. **author 를 넣지 마라** — 데이터에도 화면에도 없다(지어내면 위반). */
647
719
  export function blogPostingJsonLd(post: PostDetail, siteBase: string) {
648
720
  return {
@@ -777,8 +849,10 @@ export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: numb
777
849
  로 조회한다. 계약에 없는 데이터(예: slug→productId 매핑이 없어 후기를 못 붙임)면 **하드코딩으로 때우지
778
850
  말고 그 사실을 보고**한다. 하드코딩한 데이터는 프리뷰·실사이트에서 실데이터와 갈라져 첫인상을 죽인다.
779
851
  - ❌ **읽기 페이지를 요청마다 서버 렌더(SSR)**. ✅ SEO 페이지(홈·목록·상세·콘텐츠)는 **ISR**
780
- (`export const revalidate = N`) 또는 static 으로 둔다 — page 레벨에서 `cookies()`/`headers()`/
781
- `export const dynamic='force-dynamic'`/`fetch(...,{cache:'no-store'})` **금지**(per-page SSR 유발).
852
+ (`export const revalidate = N`) 또는 static 으로 둔다 — page 레벨에서 `next/headers` 의
853
+ `cookies()`/`headers()`/`draftMode()`/`connection()`, `export const dynamic='force-dynamic'`,
854
+ `fetch(...,{cache:'no-store'})` **금지**(per-page SSR 유발). 검사기는 **이름이 아니라 어디서
855
+ 들여왔는지**를 본다 — 같은 이름의 자기 헬퍼는 걸리지 않는다.
782
856
  실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
783
857
  (장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
784
858
  `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// zalkera-allow-dynamic:
@@ -896,14 +970,14 @@ import { ZalkeraError } from "@zalkera/client";
896
970
  try { await zalkera.checkout(input, session, idempotencyKey); }
897
971
  catch (e) {
898
972
  if (e instanceof ZalkeraError) {
899
- if (e.code === "OUT_OF_STOCK") /* 수량 줄이기 안내 — 카트 재조회로 현재 재고 표시 */;
900
- if (e.code === "ITEM_NOT_PURCHASABLE") /* 판매중지된 품목 제거 안내 */;
901
- if (e.code === "IDEMPOTENCY_CONFLICT") /* 같은 키로 다른 주문 — 키 수명 버그(§4.3). 1순위 용의자:
902
- 카트키를 멱등키로 쓰면서 성공 시 회전을 안 함 */;
903
- if (e.code === "CART_NOT_FOUND") /* 카트 만료/이미 주문됨 — 주문내역 확인 유도 */;
904
- if (e.isRateLimited) /* 429 */;
905
- if (e.isStorefrontKeyError) /* secretKey 오배선 — 서버 설정 점검(방문자 노출 X). 아래 참고 */;
906
- if (e.validationErrors.length) /* 400 필드 검증 — 필드별 메시지 노출 */;
973
+ if (e.code === "OUT_OF_STOCK") { /* 수량 줄이기 안내 — 카트 재조회로 현재 재고 표시 */ }
974
+ if (e.code === "ITEM_NOT_PURCHASABLE") { /* 판매중지된 품목 제거 안내 */ }
975
+ if (e.code === "IDEMPOTENCY_CONFLICT") { /* 같은 키로 다른 주문 — 키 수명 버그(§4.3). 1순위 용의자:
976
+ 카트키를 멱등키로 쓰면서 성공 시 회전을 안 함 */ }
977
+ if (e.code === "CART_NOT_FOUND") { /* 카트 만료/이미 주문됨 — 주문내역 확인 유도 */ }
978
+ if (e.isRateLimited) { /* 429 */ }
979
+ if (e.isStorefrontKeyError) { /* secretKey 오배선 — 서버 설정 점검(방문자 노출 X). 아래 참고 */ }
980
+ if (e.validationErrors.length) { /* 400 필드 검증 — 필드별 메시지 노출 */ }
907
981
  }
908
982
  }
909
983
  ```
@@ -912,13 +986,16 @@ catch (e) {
912
986
  - `STOREFRONT_KEY_REQUIRED`(401) — 백엔드 `required` 인데 키 없음/무효 → `secretKey` 옵션 설정(콘솔 발급→서버 `.env`).
913
987
  - `TENANT_MISMATCH`(403) — `secretKey` 가 `tenant` 와 다른 테넌트의 키 → 두 값 정합 확인.
914
988
  - 이 두 코드는 SDK 가 안내 메시지를 매핑해 둔다(`e.message` 그대로 로그에 유용).
915
- - **SDK 가 직접 만드는 코드(백엔드 `ErrorCode` 가 아니다)** — 전부 502이고 **상류·배선 문제**라, 재시도로 풀리지 않는다:
916
- - `UPSTREAM_REDIRECT` — 백엔드가 3xx 로 응답했다. **클라이언트는 리다이렉트를 따라가지 않는다**(따라가면
989
+ - **SDK 가 직접 만드는 코드(백엔드 `ErrorCode` 가 아니다)** — **상류·배선 문제**라 재시도로 풀리지 않는다:
990
+ - `UPSTREAM_REDIRECT`(502) — 백엔드가 3xx 로 응답했다. **클라이언트는 리다이렉트를 따라가지 않는다**(따라가면
917
991
  `X-Storefront-Key`·`X-Tenant`·`X-Cart-Session` 이 제3자 오리진에 그대로 전달된다). 원인은 대개 `baseUrl`
918
992
  오배선이다 — http↔https 승격, www 유무, 프록시의 경로 리라이트를 보고 **`baseUrl` 을 최종 오리진으로** 고쳐라.
919
993
  이 코드에는 우회 옵션이 없다(보안 불변식이라 스위치를 두지 않는다).
920
- - `UPSTREAM_NON_JSON` — 2xx 인데 본문이 envelope 가 아니다(게이트웨이 HTML 등).
921
- - `UPSTREAM_UNAVAILABLE` — 502·503·504 에 비JSON 본문.
994
+ - `UPSTREAM_NON_JSON`(502) — 2xx 인데 본문이 envelope 가 아니다(게이트웨이 HTML 등).
995
+ - `UPSTREAM_UNAVAILABLE`(**상류 status 그대로** — 502·503·504) — 그 status 에 비JSON 본문.
996
+ - `INVALID_PATH`(**400**) — 경로 파라미터에 `.`·`..` 세그먼트가 있다. 전송 직전에 거부하므로 요청이
997
+ 나가지 않는다. 슬러그·주문번호를 그대로 넘기기 전에 정규화하라.
998
+ - ⚠ **`e.status === 502` 로 분기하지 마라** — 위 넷의 status 가 서로 다르다. 분기는 `e.code` 로 한다.
922
999
  - `e.code` = 백엔드 `ErrorCode` enum 이름. 엔드포인트별 발생 코드 목록의 정본은 **OpenAPI**다.
923
1000
  - 코드 이름은 **전역 유일이 아니다**(ORDER_NOT_FOUND 가 여러 도메인에 있다) — 분기는 그 엔드포인트 문맥에서.
924
1001
  - 코드를 안 싣는 구버전 백엔드에서는 `e.code` 가 HTTP 사유구("Conflict")로 **폴백**한다. 폴백 값에
@@ -937,13 +1014,20 @@ catch (e) {
937
1014
  잘커라 사이트는 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
938
1015
  생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다.
939
1016
 
940
- > 이 절은 **규약이지 게이트가 아니다.** 어떤 스택으로 짜든 사이트는 개시된다 판정 잣대는 §5.1 의
941
- > 산출물 검사(`zalkera-aeo-check`)뿐이고 소스를 보지 않는다. 다만 테마 토큰을 안 쓰면 테넌트가
942
- > 콘솔에서 색을 바꿔도 화면이 안 따라온다(그건 검사기가 아니라 **기능이 빠지는** 것이다).
1017
+ > **선언을 안 했으면** 이 절은 규약이지 게이트가 아니다개시 판정은 §5.1 의 산출물 검사
1018
+ > (`zalkera-aeo-check`)뿐이고 소스를 보지 않는다.
1019
+ >
1020
+ > **`package.json` 의 `zalkera.styling` 을 선언했으면 다르다** — `zalkera-validate` 가 S 규칙을
1021
+ > **오류로 격상**하고, 색 리터럴 한 줄이면 관문이 선다(§9.1). 우리가 배송하는 팩은 전부 선언
1022
+ > 레포다. 즉 「스택은 자유」는 **미선언 레포 기준**이다.
1023
+ >
1024
+ > 어느 쪽이든, 테마 토큰을 안 쓰면 테넌트가 콘솔에서 색을 바꿔도 화면이 안 따라온다 — 그건
1025
+ > 검사기가 아니라 **기능이 빠지는** 것이다.
943
1026
 
944
1027
  **스택 — 이것만 쓴다**
945
- - ✅ **Tailwind v4 유틸리티 클래스로만** 스타일한다. CSS 파일은 `src/app/globals.css` **하나뿐**이다
946
- 새 `.css` 파일·CSS Modules·CSS-in-JS 추가하지 마라.
1028
+ - ✅ **Tailwind v4 유틸리티 클래스로만** 스타일한다. CSS 파일은 **루트가 싣는 그 하나**다 우리 팩에서는
1029
+ `src/app/globals.css` 이고, 자리는 레포마다 달라도 된다(검사기는 root layout 또는 `_app` 의
1030
+ `import "…css"` 를 읽어 그 하나를 정한다). 새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라.
947
1031
  - ❌ 인라인 `style={{}}`, 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
948
1032
  인라인 style 은 **CSS 변수 주입**(`style={{"--x":v}}`) 한 용례에만 허용된다(루트 layout 의 테마 주입이 그것).
949
1033
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalkera/client",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
5
  "license": "MIT",
6
6
  "author": "Credium Co., Ltd.",