@zalkera/client 0.6.1

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 ADDED
@@ -0,0 +1,408 @@
1
+ # zalkera (잘커라) — AI 스토어프론트 빌드 가이드 (llms.txt)
2
+
3
+ > 이 문서 하나로 잘커라 헤드리스 API 위에 **Next.js 쇼핑몰/예약 스토어프론트**를 만들 수 있다.
4
+ > 관리자 콘솔·백엔드는 잘커라가 제공한다. 당신(AI)이 만들 것은 **공개 사이트(프론트엔드)** 뿐이다.
5
+ > 타입 안전한 클라이언트 `@zalkera/client` 를 통해 백엔드를 호출한다.
6
+
7
+ ## 0. 3줄 요약
8
+
9
+ 1. `@zalkera/client` 의 `createZalkeraClient({ baseUrl, tenant, secretKey? })` 로 클라이언트를 만든다.
10
+ 2. **서버 사이드에서만 호출한다**(RSC·route handler·server action). 브라우저에서 직접 부르지 않는다.
11
+ 3. 상품 조회는 공개, 장바구니·결제·주문은 고객토큰(로그인) 또는 게스트 세션키/연락처로 식별한다.
12
+ 4. `secretKey`(있으면)는 서버 시크릿 — `.env`에만 두고 브라우저에 절대 노출하지 않는다(§2).
13
+
14
+ ## 1. 설치·초기화
15
+
16
+ ```bash
17
+ npm i ./zalkera-client-0.6.0.tgz # 배포 tarball(심볼릭 링크 말고 tarball — Turbopack 이슈)
18
+ ```
19
+
20
+ ```ts
21
+ // lib/zalkera.ts — 서버 전용 싱글턴
22
+ import { createZalkeraClient } from "@zalkera/client";
23
+
24
+ export const zalkera = createZalkeraClient({
25
+ baseUrl: process.env.ONEQUE_API_BASE!, // 예: https://api.zalkera.com (뒤에 /api 붙이지 않는다)
26
+ tenant: process.env.ONEQUE_TENANT!, // 이 사이트의 테넌트 코드. 모든 요청에 X-Tenant 로 실린다
27
+ secretKey: process.env.ONEQUE_STOREFRONT_KEY, // (선택·oqsk_…) 서버 시크릿. 있으면 X-Storefront-Key 로 실린다
28
+ });
29
+ ```
30
+
31
+ - `.env`: `ONEQUE_API_BASE`, `ONEQUE_TENANT`, (선택)`ONEQUE_STOREFRONT_KEY`. **절대 클라이언트 컴포넌트에서 import 하지 말 것** — baseUrl·시크릿 노출.
32
+ - `secretKey`는 진짜 비밀이다 — `NEXT_PUBLIC_*` 접두사·클라이언트 번들 금지. 서버 `.env`에만. 안 주면 종전대로 `tenant`(X-Tenant)만으로 동작(하위호환).
33
+ - 모든 메서드는 성공 시 데이터를, 실패 시 `ZalkeraError`(`.status`, `.code`, `.isRateLimited`, `.isStorefrontKeyError`, `.validationErrors`)를 던진다.
34
+
35
+ ## 2. 왜 서버 사이드인가 (중요)
36
+
37
+ 공개 API 는 `X-Tenant` 헤더를 그대로 믿는다(비인증). 브라우저에서 직접 부르면 baseUrl·CORS 노출.
38
+ 그래서 **RSC 또는 route handler(BFF)**에서 호출한다. 장바구니·결제처럼 사용자별 상태가 필요한 호출은
39
+ route handler 를 두고 브라우저 ↔ route handler ↔ 잘커라로 프록시한다(토큰은 httpOnly 쿠키에 보관).
40
+
41
+ **`secretKey`(스토어프론트 서버 시크릿)** — `X-Tenant` 무인증 신뢰를 키로 승격한 것. 있으면 전
42
+ 요청에 `X-Storefront-Key` 로 실려 백엔드가 테넌트 신원을 증명한다(키가 정본). 취급 규칙:
43
+ - 오직 서버 `.env`(`ONEQUE_STOREFRONT_KEY`)에만. `NEXT_PUBLIC_*`·클라이언트 번들 절대 금지 — 이 클라이언트가 서버 전용인 이유.
44
+ - 파트너 콘솔에서 발급(원문 1회 노출·분실 시 회전). 유출 시 콘솔에서 revoke·재발급.
45
+ - 안 주면 종전대로 `tenant`만으로 동작(하위호환). 백엔드가 키를 요구하는 설정이면 키 없이 401 `STOREFRONT_KEY_REQUIRED`.
46
+
47
+ IP 민감 호출(문의·리드·조회수)은 원 방문자 IP 를 넘겨야 한다:
48
+ ```ts
49
+ // route handler 안
50
+ const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
51
+ await zalkera.submitInquiry(input, { clientIp: ip });
52
+ ```
53
+
54
+ ## 3. API 표면 (전체 메서드)
55
+
56
+ ### 콘텐츠(CMS)
57
+ - `getSiteConfig()` — 회사명·연락처·테마·SEO 기본값
58
+ - `listCategories()` · `listPosts({category?,page?,size?,sort?})` · `getPost(slug)` · `recordPostView(slug,ctx)`
59
+ - `getPage(slug)` · `listMenus()` (HEADER/FOOTER) · `getMediaUrl(id)`
60
+ - `submitInquiry(input, ctx)` · `submitLead(input, ctx)` ← ctx.clientIp 필수(서버)
61
+
62
+ ### 커머스 — 카탈로그(공개)
63
+ - `listProducts({productType?, keyword?, page?, size?, sort?})` → `Paginated<ProductSummary>` (카드용: slug·name·priceFrom·inStock)
64
+ - `getProduct(slug)` → `ProductDetail { name, productType, variants[] }`
65
+ - `listProductCategories()` → `ProductCategory[]`
66
+ - **variant 가 판매 단위다.** 가격·재고·장바구니·주문은 전부 `variant.id` 기준. 단순 상품도 variant 1개.
67
+ - `variant.inStock`(불리언)·`variant.available`(추적 재고 수량, 무한재고면 null)로 재고 표시.
68
+ - `productType`: `PHYSICAL`(배송)·`DIGITAL`(즉시)·`SERVICE`(예약).
69
+ **`SERVICE` 는 장바구니·체크아웃을 타지 않는다** — 별도 예약 흐름(§4.6). 담기 시도는 400 이다.
70
+
71
+ ### 커머스 — 상품후기(공개)
72
+ - `listProductReviews(productId, {page?, size?})` → `Paginated<Review>` (공개·VISIBLE 만, **최신순 고정**)
73
+ - `Review { id, productId, rating(1~5), title, content, photos[], createdAt }`. 작성자 신원은 노출 안 함.
74
+ - `photos` 는 media asset id 배열 → `getMediaUrl(id)` 로 URL 화.
75
+ - `getProductReviewSummary(productId)` → `RatingSummary { productId, reviewCount, averageRating }` (후기 없으면 0·0)
76
+ - `createProductReview(productId, {orderItemId, rating(1~5), title?, content, photos?}, accessToken)` → `Review`
77
+ **로그인 필수 + 구매검증**: orderItemId 는 **주문 상세(getOrder)의 items[].id** 에서 얻는다(다른 데선 못 얻음).
78
+ 서버가 본인 주문·배송완료(DELIVERED)·상품 일치 3중 확인. 라인당 1개(재작성 409).
79
+ - 목록 카드의 별점 배지처럼 값싸게 쓴다(product 비정규화 집계에서 읽음).
80
+ - **후기는 상품의 숫자 `id`(productId)로 조회한다 — slug 가 아니다.** `getProduct(slug)` → `ProductDetail.id`
81
+ 로 획득한다(0.4.0 에서 ProductDetail 에 id 노출). 즉 상품 상세에서 `const p = await getProduct(slug);
82
+ const reviews = await listProductReviews(p.id);` 로 곧바로 붙인다. **후기 데이터는 하드코딩하지 않는다.**
83
+
84
+ ### 예약 (SERVICE 상품 — 카트 안 탐)
85
+ - `availability({productId, from, to}, options?)` → `AvailabilitySlot[]` (**공개**·비로그인 가능)
86
+ - `{ slotId, resourceId, startAt, endAt, availableCount }`. 미래·잔여정원 있는 OPEN 슬롯만.
87
+ - **가용 판정은 `availableCount`** — 카탈로그의 `inStock`/`available` 아님(슬롯마다 정원이 따로).
88
+ - `createBooking(accessToken, { slotId, quantity?, contactName?, contactPhone? })` → `Booking` (**로그인 필수**)
89
+ - `Booking { bookingCode, productId, slotId, startAt, endAt, quantity, status, orderNo, createdAt }`
90
+ - **`orderNo` 가 분기다**: 유료·예약금이면 `status=PENDING` + orderNo → `startPayment(orderNo)` 로
91
+ 기존 결제 흐름(§4.3). 무료면 orderNo=null·즉시 `CONFIRMED`.
92
+ - `myBookings(accessToken)` · `cancelBooking(accessToken, bookingCode)` ·
93
+ `rescheduleBooking(accessToken, bookingCode, newSlotId)` — 전부 **bookingCode** 로(숫자 id 아님).
94
+
95
+ ### 커머스 — 고객 인증(소셜)
96
+ - `socialLogin({ provider, code, redirectUri? })` → `AuthTokens { accessToken, refreshToken, customer }`
97
+ - provider: `KAKAO`|`NAVER`|`GOOGLE`. 소셜 리다이렉트로 받은 `code` 를 넘기면 백엔드가 교환.
98
+ - `refreshSession(refreshToken)` → 새 토큰(회전). `logout(accessToken)`. `getMe(accessToken)`.
99
+ - `getConsents(accessToken)` → `ConsentStatus[]` · `updateConsents(accessToken, consents)` → 확인 메시지 (마케팅 수신 토글 등·append).
100
+ - **accessToken 은 15분**, refreshToken 은 매 교환마다 회전. 둘 다 httpOnly 쿠키에 저장 권장.
101
+
102
+ ### 커머스 — 장바구니 (session = { accessToken? , cartSessionKey? })
103
+ - `getCart(session)` · `addToCart(variantId, qty, session)` · `updateCartItem(variantId, qty, session)`
104
+ · `removeFromCart(variantId, session)` · `clearCart(session)`
105
+ - 로그인 고객은 `accessToken`, **게스트는 `cartSessionKey`**(브라우저가 만든 안정적 랜덤 문자열, 쿠키 보관).
106
+ - 카트는 가격을 동결하지 않는다 — 조회할 때마다 현재가. 재고도 잠그지 않는다.
107
+
108
+ ### 커머스 — 결제·주문·배송
109
+ - `checkout(input, session)` → `OrderDetail` (input: `{ buyerName, buyerPhone, buyerEmail?, shipTo? }`)
110
+ - 주문 생성 시 가격 스냅샷 동결 + 재고 원자적 차감. 재고 부족이면 409. 상태 `PENDING_PAYMENT`.
111
+ - `startPayment(orderNo, access)` → `{ paymentUrl }` — 고객을 paymentUrl 로 리다이렉트.
112
+ - `getOrder(orderNo, access)` · `listMyOrders(accessToken,{page,size})` · `cancelOrder(orderNo, access)`
113
+ · `completeOrder(orderNo, access)` · `getShipment(orderNo, access)`
114
+ - `access = { accessToken? }`(로그인) **또는 `{ phone? }`(게스트, 주문 시 남긴 연락처)**.
115
+
116
+ ## 4. 레시피
117
+
118
+ ### 4.1 상품 목록 페이지 (RSC)
119
+ ```tsx
120
+ // app/products/page.tsx
121
+ import { zalkera } from "@/lib/zalkera";
122
+ export default async function Products() {
123
+ const categories = await zalkera.listProductCategories();
124
+ // 카테고리별 상품 목록은 카테고리 페이지에서. 여기선 카테고리 내비 + (별도) 상품 카드.
125
+ return <nav>{categories.map(c => <a key={c.id} href={`/c/${c.slug}`}>{c.name}</a>)}</nav>;
126
+ }
127
+ ```
128
+
129
+ ### 4.2 상품 상세 + 장바구니 담기
130
+ ```tsx
131
+ // app/products/[slug]/page.tsx (RSC) — 조회는 공개
132
+ const product = await zalkera.getProduct(params.slug);
133
+ // 클라이언트 컴포넌트에서 variant 선택 → route handler 로 담기 POST
134
+ ```
135
+ ```ts
136
+ // app/api/cart/add/route.ts (BFF)
137
+ export async function POST(req: Request) {
138
+ const { variantId, quantity } = await req.json();
139
+ const session = { accessToken: cookieAccessToken(), cartSessionKey: ensureCartCookie() };
140
+ const cart = await zalkera.addToCart(variantId, quantity, session);
141
+ return Response.json(cart);
142
+ }
143
+ ```
144
+ - `ensureCartCookie()`: 게스트면 랜덤 키를 만들어 httpOnly 쿠키에 저장하고 그 값을 쓴다.
145
+
146
+ ### 4.3 결제 플로우 (게스트/로그인 공통)
147
+ 1. 카트 확인 → `checkout({ buyerName, buyerPhone, shipTo }, session, idempotencyKey)` → `order.orderNo`.
148
+ 2. `startPayment(order.orderNo, access)` → **벤더에 따라 두 갈래**(테넌트가 자기 PG 를 고른다 — 기본 TOSS):
149
+ ```ts
150
+ const session = await zalkera.startPayment(orderNo, access);
151
+ if (session.widget) {
152
+ // 위젯형(토스) — 내 사이트에서 결제창을 띄우고, 성공 콜백 파라미터를 confirm 으로 넘긴다
153
+ // widget = { vendor:"TOSS_PAYMENTS", clientKey, orderId, amount, orderName }
154
+ // → 스타터의 checkout/page.tsx(openWidget)·payment/complete/page.tsx 를 그대로 쓴다
155
+ await zalkera.confirmPayment(orderNo, { paymentKey }, access); // BFF 경유
156
+ } else {
157
+ redirect(session.paymentUrl); // 리다이렉트형(PayOneQ 등)
158
+ }
159
+ ```
160
+ `widget` 유무로만 분기한다 — **벤더 이름으로 분기하지 마라**(벤더가 늘어난다).
161
+ 3. 페이원큐 결제창에서 결제. **결과는 서버 웹훅으로 백엔드가 처리**(프론트는 관여 안 함).
162
+ 4. returnUrl 로 돌아오면 `getOrder(orderNo, access)` 로 상태 확인(잠시 PENDING 일 수 있음 → PAID 폴링/새로고침).
163
+
164
+ > ⚠️ returnUrl·successUrl 콜백의 "성공"을 **신뢰하지 말 것**. 진짜 확정은 백엔드가 한다 — 웹훅(리다이렉트형)
165
+ > 또는 confirm(위젯형: 서버가 **저장된 주문 금액**으로 PG 에 직접 묻는다). 콜백의 `amount` 를 넘겨도 서버는
166
+ > 안 믿으니 브라우저에서 금액을 조작해도 승인되지 않는다. 프론트는 화면 전환만, 상태는 항상 `getOrder`.
167
+ > `confirmPayment` 는 **재호출이 안전**하다(이미 승인된 주문은 조용히 통과 — 웹훅이 먼저 와도 멱등).
168
+
169
+ > **멱등키(권장)** — `checkout` 3번째 인자. 같은 키·같은 입력의 재요청은 새 주문을 만들지 않고 **원주문을
170
+ > 그대로 반환**한다(더블클릭·네트워크 재시도 안전). 같은 키·다른 입력은 409.
171
+ > **키의 수명 = "주문 시도 1묶음"(성공이 브라우저에 도달할 때까지). 새 주문은 새 키다.** 호출마다 새로
172
+ > 만들면 재시도가 서로 다른 키를 들고 가 아무것도 못 막고, 반대로 **키를 영구 재사용하면 그 브라우저는
173
+ > 두 번째 주문을 영영 못 한다**(같은 키·다른 입력 → 409). SDK 가 자동 생성하지 않는 이유가 이것이다 —
174
+ > 이 수명을 아는 건 앱뿐이다.
175
+ >
176
+ > 관용구는 소비자에 따라 둘이다:
177
+ > - **브라우저(쿠키 있음)** = `` `co-${session.cartSessionKey}` `` **+ 체크아웃 성공 응답에서 카트 쿠키 회전**
178
+ > (스타터 `rotateCartSessionKey`). **회전 없이 카트키를 멱등키로 쓰지 마라** — 카트 쿠키는 30일이라
179
+ > 회전이 없으면 위의 "영구 재사용"이 그대로 실현된다(실측 사고). 회전을 응답에 실으면 Set-Cookie 가
180
+ > 성공 도달과 원자적으로 묶여서, 응답 유실 시엔 옛 키가 살아남아 재시도가 원주문을 재생한다 —
181
+ > **인메모리 "시도당 키"보다 이 쪽이 안전하다**(새로고침에 안 죽는다).
182
+ > - **AI 구매 에이전트(쿠키 없음)** = 시도당 UUID 를 스스로 만들어 **성공·확정까지 보관**하고 재시도에 재사용.
183
+ >
184
+ > 생략하면 종전 동작(중복 제출은 카트가 이미 소비돼 404 `CART_NOT_FOUND`).
185
+ > 충돌은 `e.code === "IDEMPOTENCY_CONFLICT"` 로 **프로그램적으로 감지**한다(§6).
186
+
187
+ ### 4.4 게스트 주문 조회 (비회원 배송조회)
188
+ ```ts
189
+ // 주문번호 + 연락처로 조회 — 로그인 불필요
190
+ const order = await zalkera.getOrder(orderNo, { phone });
191
+ const shipment = await zalkera.getShipment(orderNo, { phone }); // 배송 상태·추적 이벤트
192
+ ```
193
+
194
+ ### 4.5 소셜 로그인 (카카오 예시)
195
+ 1. 프론트에서 카카오 인가 URL 로 보냄 → 카카오가 `code` 로 redirect.
196
+ 2. 콜백 route handler 에서 `zalkera.socialLogin({ provider:"KAKAO", code, redirectUri })`.
197
+ 3. 받은 `accessToken`·`refreshToken` 을 httpOnly 쿠키에 저장. 이후 shop 호출에 `accessToken` 사용.
198
+ 4. 만료(401)면 `refreshSession(refreshToken)` 으로 회전 후 재시도.
199
+
200
+ ### 4.6 예약 서비스(SERVICE 상품) — **카트를 타지 않는다**
201
+
202
+ > ⚠️ **`SERVICE` 상품은 장바구니에 담기지 않는다**(`addToCart` → 400). 예약은 슬롯을 잡는 일이라
203
+ > 카트에 실으면 체크아웃이 오염된다(혼합주문). **별도 흐름**을 탄다.
204
+ > (이 문단은 예전에 "같은 카트·주문 흐름을 탄다"고 적혀 있었다 — 예약 엔진이 붙기 전 이야기였고,
205
+ > 지금은 틀렸다. 그 말을 믿고 만든 담기 버튼은 400 으로 죽는다.)
206
+
207
+ **상품 상세에서 `productType === "SERVICE"` 면 담기 버튼 대신 슬롯 선택을 그린다.**
208
+
209
+ ```tsx
210
+ // 1) 슬롯 보여주기 — 공개(비로그인 가능).
211
+ // ⚠️ 이 RSC 직독 샘플은 **요청마다 렌더되는 동적 라우트에서만** 신선하다. 상품 상세가 ISR
212
+ // (force-static + revalidate=N)이면 슬롯이 프리렌더에 구워져 N초 낡은 시간표를 보여주고
213
+ // SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(스타터
214
+ // BookingPanel + /api/booking/availability 가 그 선례다. 볼라틸한 데이터는 클라이언트 아일랜드로).
215
+ const slots = await zalkera.availability({
216
+ productId: product.id, // slug 아님 — getProduct(slug).id
217
+ from: new Date().toISOString(),
218
+ to: new Date(Date.now() + 14 * 864e5).toISOString(),
219
+ });
220
+ // 가용 판정은 slot.availableCount — 카탈로그의 inStock/available 이 아니다(슬롯마다 정원이 따로).
221
+ ```
222
+
223
+ ```ts
224
+ // 2) 예약 잡기 — 로그인 필수(게스트 예약 없음). BFF route handler 에서.
225
+ const booking = await zalkera.createBooking(accessToken, { slotId, quantity: 1 });
226
+
227
+ // 3) 유료·예약금이면 결제로 — 예약 전용 결제 API 는 없다. 기존 흐름을 그대로 탄다.
228
+ if (booking.orderNo) { // status=PENDING
229
+ const payment = await zalkera.startPayment(booking.orderNo, { accessToken });
230
+ // 이후는 §4.3 과 완전히 동일(widget 유무로 분기).
231
+ }
232
+ // 무료 예약이면 orderNo=null 이고 이미 CONFIRMED — 결제 단계가 없다.
233
+ ```
234
+
235
+ - 그 외: `myBookings(accessToken)` · `cancelBooking(accessToken, code)`(시작 전·무료만) ·
236
+ `rescheduleBooking(accessToken, code, newSlotId)`(같은 상품의 다른 슬롯).
237
+ - **취소·변경은 `bookingCode`** 로 한다(숫자 id 아님).
238
+ - **errorCode**(전부 실코드 — 의미까지 실측한 것만 적었다):
239
+ - 생성: `SLOT_FULL`(**정원 부족·마감·과거 슬롯 전부 이것** — 원자 소비 실패라 한 코드로 뭉친다.
240
+ 목록이 stale 하니 `availability` 재조회 후 다시 보여줘라) · `SLOT_NOT_FOUND`(404) ·
241
+ `QUANTITY_EXCEEDED`(**1회 예약 수량 한도(10) 초과 또는 1 미만** — 잔여 부족이 아니다. 잔여
242
+ 부족은 `SLOT_FULL` 이다).
243
+ - 취소: `PAID_CANCEL_ADMIN_ONLY`(**유료 예약은 고객이 못 취소한다 — 매장 문의 안내를 띄워라**) ·
244
+ `CANCEL_AFTER_START`(이미 시작함) · `INVALID_BOOKING_STATE`(이미 종단) · `BOOKING_NOT_FOUND`.
245
+ - 변경: `SLOT_PRODUCT_MISMATCH`(다른 상품의 슬롯으로는 못 옮긴다) · `SLOT_FULL` ·
246
+ `SLOT_NOT_FOUND` · `CANCEL_AFTER_START` · `INVALID_BOOKING_STATE` · `BOOKING_NOT_FOUND`.
247
+ - 공통: `CUSTOMER_INACTIVE`(로그인 4액션 전부 — 계정 비활성) · 미로그인 401.
248
+ - ⚠️ **남의 예약도 `BOOKING_NOT_FOUND`(404)** 다 — 서버가 존재를 감춘다(403 아님). "권한 없음"
249
+ 코드는 없으니 찾지 마라.
250
+
251
+ ### 4.7 광고 랜딩 리드 폼 (`submitLead`)
252
+
253
+ - **문의(§4.6 아님·`submitInquiry`)와 다른 점**: 연락처(phone)가 필수·이메일이 선택이고, 광고 유입
254
+ 추적(UTM·클릭ID)을 `tracking` 으로 동봉한다. 반환은 `{id}`, 레이트리밋 30건/60초(테넌트×IP).
255
+ - **쿼리 → `LeadTracking` 매핑 8종**: `utm_source/medium/campaign/adgroup/content` → `utmSource/…`,
256
+ `fbclid`·`gclid`·`nclid` 동명. **UTM 은 클라이언트 아일랜드가 mount 후 `window.location.search`
257
+ 로 캡처**한다 — 랜딩이 ISR(force-static)이면 RSC `searchParams`·`useSearchParams()` 는 정적 셸을
258
+ 깨므로 금지(하나라도 있으면 `tracking` 을 채우고, 전무면 undefined).
259
+ - **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `x-forwarded-for` 첫
260
+ 홉을 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
261
+ - 선례: `components/LeadForm.tsx`(아일랜드·UTM 캡처) · `app/api/lead/route.ts`(BFF·201 관통).
262
+
263
+ ## 5. 흔한 실수(하지 말 것)
264
+
265
+ - ❌ 클라이언트 컴포넌트에서 `@zalkera/client` import → baseUrl 노출. ✅ 서버에서만.
266
+ - ❌ variant 없이 product.id 로 장바구니 담기. ✅ 항상 `variant.id`.
267
+ - ❌ **예약(SERVICE) 상품에 담기 버튼**. ✅ `productType === "SERVICE"` 면 슬롯 선택(§4.6) — 담기는 400 이다. 예약 CTA 는 상품 상세로 보내는데 거기 담기밖에 없으면 고객이 죽는 경로가 된다.
268
+ - ❌ 게스트 카트에 cartSessionKey 안 넘김 → 매 요청 새 카트. ✅ 쿠키로 안정적 키 유지.
269
+ - ❌ 카트키를 멱등키로 쓰면서 체크아웃 성공 시 **회전을 안 함** → 그 게스트는 30일간 재주문 불가(409).
270
+ ✅ 성공 응답에서 카트 쿠키 회전(`rotateCartSessionKey`) — 회전과 카트키 멱등은 한 세트다.
271
+ - ❌ returnUrl "결제성공"을 믿고 주문완료 처리. ✅ `getOrder` 로 상태 확인(웹훅이 정본).
272
+ - ❌ 재고를 `variant.available` 숫자로만 판단. ✅ 무한재고(null)는 `inStock` 으로.
273
+ - ❌ **인라인 `style={{}}` 로 화면 구성**. ✅ Tailwind 유틸리티 클래스로만 스타일한다(§8). 인라인 style 은
274
+ CSS 변수 주입(`style={{"--...": ...}}`)에만 허용된다.
275
+ - ❌ **`var(--oneq-*)` 참조**(정의처 없는 죽은 레거시 토큰). ✅ 테넌트 색은 토큰 유틸리티(`bg-primary`·
276
+ `text-primary` 등, §8)로 쓴다.
277
+ - ❌ 서버에서 `submitInquiry`/`submitLead`/`recordPostView` 부를 때 clientIp 누락 → 방문자 전원 rate-limit.
278
+ - ❌ **상품·후기·게시글 등 도메인 데이터를 하드코딩**(리터럴 배열·목업 후기 등). ✅ 반드시 `@zalkera/client`
279
+ 로 조회한다. 계약에 없는 데이터(예: slug→productId 매핑이 없어 후기를 못 붙임)면 **하드코딩으로 때우지
280
+ 말고 그 사실을 보고**한다. 하드코딩한 데이터는 프리뷰·실사이트에서 실데이터와 갈라져 첫인상을 죽인다.
281
+ - ❌ **읽기 페이지를 요청마다 서버 렌더(SSR)**. ✅ SEO 페이지(홈·목록·상세·콘텐츠)는 **ISR**
282
+ (`export const revalidate = N`) 또는 static 으로 둔다 — page 레벨에서 `cookies()`/`headers()`/
283
+ `export const dynamic='force-dynamic'`/`fetch(...,{cache:'no-store'})` **금지**(per-page SSR 유발).
284
+ 실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
285
+ (장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
286
+ `POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// oneque-allow-dynamic:
287
+ <이유>`)이 필요하다 — 서버 동적 렌더는 상시 런타임 원가라 CI validator(§5)가 SEO 라우트에서 강제 차단한다.
288
+
289
+ ## 5.1 산출물 규범 — 발견되는 사이트 (필수)
290
+
291
+ 만드는 사이트는 **사람이 보는 UI**와 **검색·AI 가 읽는 구조**를 동시에 만족해야 한다. 아래는 선택이 아니다.
292
+ 독립 브랜드 사이트가 발견되는 경로는 검색 리치결과·AI 답변(ChatGPT 등)이고, 그 입장권이 이것들이다.
293
+
294
+ - ✅ **SEO 페이지에 JSON-LD(schema.org) 필수.** 상품 상세 = `Product` + variant 마다 `Offer`(price·
295
+ priceCurrency·availability) + 후기 있으면 `AggregateRating`. 홈 = `Organization`(오프라인 점포면
296
+ `LocalBusiness`, 뷰티샵이면 `BeautySalon` 으로 좁힌다). 목록·상세엔 `BreadcrumbList`.
297
+ 스타터의 `src/components/JsonLd.tsx`(안전 직렬화 + `productJsonLd`/`organizationJsonLd`/
298
+ `breadcrumbJsonLd` 헬퍼)를 **그대로 쓴다 — 재발명 금지**.
299
+ - ✅ **`sitemap.ts`·`robots.ts` 필수.** sitemap 엔 **실제로 존재하는 공개 라우트만**(홈·상품 상세·콘텐츠).
300
+ robots 는 세션·쓰기 경로(`/api/`·`/cart`·`/checkout`·`/mypage`·`/orders`·`/login`·`/auth`)만 막고
301
+ 공개 카탈로그는 전부 연다. **AI 크롤러(GPTBot·ClaudeBot 등)를 막지 마라** — 발견 경로를 우리 손으로 닫는 것이다.
302
+ - ✅ **절대 URL.** JSON-LD·sitemap·robots 는 상대경로 불가. 단일 사이트는 env(`ONEQUE_SITE_URL`),
303
+ 멀티테넌트는 요청 호스트에서 만든다.
304
+ - ✅ **ISR 유지.** JSON-LD 를 넣는다고 페이지를 동적으로 만들지 마라 — 전부 프리렌더 값이다(§5 ISR-우선과 한 몸).
305
+ `sitemap.ts`/`robots.ts` 는 page 가 아니라 크롤러 엔드포인트라 호스트 기반 동적이 정당한 예외다.
306
+ - ❌ **페이지에 없는 것을 JSON-LD 에 쓰지 마라.** 구조화 데이터는 **보이는 내용만** 서술한다. 후기 0건인데
307
+ `aggregateRating`, 렌더하지도 않는 `image`, 지어낸 브랜드·재고는 전부 구조화 데이터 정책 위반(리치결과 박탈).
308
+ 값이 없으면 **그 필드를 통째로 뺀다**(널·0 을 넣지 않는다).
309
+ - ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.** 스타터의 `src/app/media/[id]/route.ts` 가 `X-Tenant` 를 붙여
310
+ 백엔드를 부르고 302 Location 만 넘긴다(바이트는 스토리지→브라우저 직행 — Next 런타임에 태우지 마라).
311
+ `<img src={`/media/${product.coverAssetId}`} loading="lazy">` + JSON-LD `image` 둘 다 이 URL 을 쓴다.
312
+ `next/image` 는 최적화 프록시가 바이트를 런타임에 태우므로 금지(쓰려면 `unoptimized`).
313
+ - ❌ **`getMediaUrl` 의 presigned URL 을 마크업·JSON-LD 에 직접 쓰지 마라.** 수 분 뒤 죽는다 — ISR 로 캐시된
314
+ HTML 안에서(상세 revalidate 300s) 서명이 먼저 만료돼 이미지가 깨지고, 크롤러가 캐시하면 JSON-LD 도 죽는다.
315
+
316
+ - ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
317
+ 버튼을 낸다(상태로 게이트). orderItemId 는 주문 상세 items[].id 에서만 얻는다 — 상품 상세엔 없다.
318
+
319
+ ## 6. 에러 처리
320
+
321
+ **원인 분기는 `e.code`(기계 판독 `errorCode`)로 한다.** 한 엔드포인트가 같은 상태코드로 여러 원인을 낸다 —
322
+ checkout 의 409만 해도 재고부족·판매불가·멱등충돌이고 **처방이 전부 다르다**. `e.message` 는 사람용
323
+ 한국어라 **문자열 매칭 금지**(문구가 바뀌면 분기가 깨진다).
324
+
325
+ ```ts
326
+ import { ZalkeraError } from "@zalkera/client";
327
+ try { await zalkera.checkout(input, session, idempotencyKey); }
328
+ catch (e) {
329
+ if (e instanceof ZalkeraError) {
330
+ if (e.code === "OUT_OF_STOCK") /* 수량 줄이기 안내 — 카트 재조회로 현재 재고 표시 */;
331
+ if (e.code === "ITEM_NOT_PURCHASABLE") /* 판매중지된 품목 제거 안내 */;
332
+ if (e.code === "IDEMPOTENCY_CONFLICT") /* 같은 키로 다른 주문 — 키 수명 버그(§4.3). 1순위 용의자:
333
+ 카트키를 멱등키로 쓰면서 성공 시 회전을 안 함 */;
334
+ if (e.code === "CART_NOT_FOUND") /* 카트 만료/이미 주문됨 — 주문내역 확인 유도 */;
335
+ if (e.isRateLimited) /* 429 */;
336
+ if (e.isStorefrontKeyError) /* secretKey 오배선 — 서버 설정 점검(방문자 노출 X). 아래 참고 */;
337
+ if (e.validationErrors.length) /* 400 필드 검증 — 필드별 메시지 노출 */;
338
+ }
339
+ }
340
+ ```
341
+
342
+ - **스토어프론트 키 오배선은 개발자 신호다**(방문자에게 노출 금지): `e.isStorefrontKeyError` 로 묶어 잡는다.
343
+ - `STOREFRONT_KEY_REQUIRED`(401) — 백엔드 `required` 인데 키 없음/무효 → `secretKey` 옵션 설정(콘솔 발급→서버 `.env`).
344
+ - `TENANT_MISMATCH`(403) — `secretKey` 가 `tenant` 와 다른 테넌트의 키 → 두 값 정합 확인.
345
+ - 이 두 코드는 SDK 가 안내 메시지를 매핑해 둔다(`e.message` 그대로 로그에 유용).
346
+ - `e.code` = 백엔드 `ErrorCode` enum 이름. 엔드포인트별 발생 코드 목록의 정본은 **OpenAPI**다.
347
+ - 코드 이름은 **전역 유일이 아니다**(ORDER_NOT_FOUND 가 여러 도메인에 있다) — 분기는 그 엔드포인트 문맥에서.
348
+ - 코드를 안 싣는 구버전 백엔드에서는 `e.code` 가 HTTP 사유구("Conflict")로 **폴백**한다. 폴백 값에
349
+ 의존하는 분기를 짜지 마라.
350
+
351
+ ## 7. 결제 파트너
352
+
353
+ 결제·정산은 **페이원큐(PayOneQ)** 가 실행한다. 프론트는 카드정보를 절대 다루지 않는다(PCI 범위 밖).
354
+ `startPayment` 가 준 URL 로 보내고, 결과는 백엔드가 웹훅으로 받아 주문 상태를 바꾼다.
355
+
356
+ ## 8. 스타일 규약 — Tailwind v4 + 테마 토큰 (필수)
357
+
358
+ 스타터(템플릿)는 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
359
+ 생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다. CI validator(S1~S5)가 강제한다.
360
+
361
+ **스택 — 이것만 쓴다**
362
+ - ✅ **Tailwind v4 유틸리티 클래스로만** 스타일한다. CSS 파일은 `src/app/globals.css` **하나뿐**이다 —
363
+ 새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라(S3·S5).
364
+ - ❌ 인라인 `style={{}}`(S2), 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
365
+ 인라인 style 은 **CSS 변수 주입**(`style={{"--x":v}}`) 한 용례에만 허용된다(루트 layout 의 테마 주입이 그것).
366
+
367
+ **토큰 — 색은 토큰으로만**
368
+ | 유틸리티 | 뜻 |
369
+ |---|---|
370
+ | `bg-primary` `text-primary` `border-primary` | 테넌트 액센트(브랜드색). CTA·가격·강조에만 |
371
+ | `text-primary-foreground` | primary 배경 위 글자(대비 자동 산출) |
372
+ | `bg-background` `text-foreground` | 페이지 배경·본문 글자 |
373
+ | `text-muted` | 보조 텍스트(설명·메타) |
374
+ | `border-border` | 경계선 | `bg-surface` | 카드·필드 배경 | `text-danger` | 오류 |
375
+
376
+ - ✅ 테넌트 브랜드색은 **`primary` 토큰으로만** 표현한다. ❌ hex 하드코딩(`bg-[#e91e63]`)은 콘솔의
377
+ "말로 색 바꾸기"를 죽인다(S4).
378
+ - ✅ 중립 명도 단계가 토큰 표에 없으면 **slate 스케일만**(`text-slate-400` 등). ❌ gray·zinc·stone 혼용,
379
+ 유채색 팔레트(rose·emerald 등) 직접 사용 금지 — 새 색이 필요하면 **지어내지 말고 보고**한다.
380
+ - 간격·타이포·라운드·섀도는 **Tailwind 기본 스케일**(`p-4`·`text-2xl`·`rounded-lg`·`shadow-sm`)을 쓴다.
381
+ 임의값(`[...]`)은 **레이아웃 치수에만**(예: `grid-cols-[repeat(auto-fill,minmax(240px,1fr))]`), 색에는 금지.
382
+
383
+ **레시피**
384
+ - 페이지 `<main className="py-8">` · 섹션 `<section className="mb-12">` · 제목은 base 가 크기를 주므로 `<h1>` 그대로.
385
+ - 버튼은 `src/components/ui/Button`(`<Link>`·`<a>` 에는 `buttonClasses(variant)` 문자열) · 카드는 `ui/Card`.
386
+ - 폼 필드(`<input>`·`<select>`·`<textarea>`)는 base 레이어가 스타일하므로 **맨 요소 그대로** 쓴다.
387
+
388
+ **절제 — 화려하게 만들지 마라**
389
+ - ❌ `animate-*`·커스텀 keyframes·그라데이션·`backdrop-*`. ✅ `hover:` 색 변화·`transition-colors`·`shadow-sm` 까지.
390
+
391
+ **전역 토큰 knob — 말로 폰트·모서리·밀도 바꾸기 (계약버전 2)**
392
+ 색 외에 **폰트·모서리 둥글기·여백 밀도**도 테넌트가 콘솔에서 "말로" 바꾼다(`site.theme.update`). 값은 **enum**
393
+ 뿐이며 `src/lib/theme.ts` 매핑 테이블이 CSS 변수로 변환해 색과 같은 경로(`<html>` inline 주입)로 전 화면에 반영된다.
394
+
395
+ | knob | enum 값 | 효과 |
396
+ |---|---|---|
397
+ | `font` | `system`(기본) · `pretendard` · `noto-serif-kr` | 전역 폰트(`--font-sans`). system=시스템 산세리프, pretendard=산세리프, noto-serif-kr=명조/세리프 |
398
+ | `radius` | `sharp` · `soft`(기본) · `round` | 모서리 둥글기 — `rounded-*` 스케일 전체를 배수 조정(sharp=각짐, round=곱절) |
399
+ | `density` | `compact` · `cozy`(기본) | 여백 밀도 — `--spacing` 베이스 조정으로 `p-*`·`gap-*` 등 전 간격 유틸리티가 일괄 스케일 |
400
+
401
+ - ✅ 폰트·모서리·밀도 변경 요청("폰트 바꿔줘", "모서리 각지게", "더 촘촘하게")은 **코드 수정이 아니라 이 knob** 이다 —
402
+ 콘솔에서 바뀌고 재코딩 불요. ❌ 임의 폰트(`font-['Comic Sans']`)·`font-face`·개별 요소 라운드 하드코딩으로 우회 금지.
403
+ - 새 knob 값(예: 다른 폰트)이 필요하면 **지어내지 말고 보고**한다 — enum 확장은 계약버전 증가를 동반한다.
404
+
405
+ **테마 동작 — 배선을 건드리지 마라**
406
+ - 테넌트 색은 `getSiteConfig().themeColors` → **루트 `layout` 이 `<html>` 의 CSS 변수로 주입**한다. 이 배선
407
+ (layout 의 테마 주입·`src/lib/theme.ts`)을 제거·우회하지 마라. **색 변경에 코드 수정이 필요하면 설계 위반**이다 —
408
+ 콘솔에서 색을 바꾸면 재코딩 없이 반영되는 것이 정상이다.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@zalkera/client",
3
+ "version": "0.6.1",
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
+ "oneque",
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
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "devDependencies": {
45
+ "tsup": "^8.3.5",
46
+ "typescript": "^5.7.2",
47
+ "vitest": "^2.1.8"
48
+ }
49
+ }