@zalkera/client 0.7.2 → 0.9.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/README.md +14 -0
- package/dist/index.cjs +51 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -3
- package/dist/index.d.ts +21 -3
- package/dist/index.js +51 -22
- package/dist/index.js.map +1 -1
- package/llms.txt +21 -10
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -67,6 +67,18 @@ interface SiteConfig {
|
|
|
67
67
|
* 정책 문구와 이 값이 갈리지 않도록 금액은 항상 이 필드를 쓴다.
|
|
68
68
|
*/
|
|
69
69
|
defaultReturnShippingFee: number | null;
|
|
70
|
+
/**
|
|
71
|
+
* 영업시간 JSON 문자열(스키마리스 — 테넌트가 채운다). 미설정이면 null.
|
|
72
|
+
* 권장 구조: `{weekly:[{dayOfWeek:["Monday",…], opens:"10:00", closes:"20:00"}], closedNote?:string}`.
|
|
73
|
+
*
|
|
74
|
+
* `dayOfWeek`·`opens`·`closes` 는 schema.org `OpeningHoursSpecification` 의 속성명을 그대로 쓴다 —
|
|
75
|
+
* 중간 어휘를 하나 더 만들면 매핑표가 사본이 되고, 사본은 갈라진다.
|
|
76
|
+
*
|
|
77
|
+
* **null 이면 JSON-LD 에서 필드를 통째로 빼라.** 영업시간을 모르는 채 "영업시간 있음"만 주장하는 것은
|
|
78
|
+
* 구조화 데이터로서 거짓이고, `LocalBusiness` 는 그 거짓이 곧바로 사람의 헛걸음이 되는 타입이다.
|
|
79
|
+
* **파싱 실패해도 죽으면 안 된다** — 패스스루라 어떤 값이든 올 수 있다.
|
|
80
|
+
*/
|
|
81
|
+
openingHours: string | null;
|
|
70
82
|
}
|
|
71
83
|
/** 테넌트 업종. 서버가 값을 추가할 수 있다 — 모르는 값은 일반(Organization)으로 흘려보내라. */
|
|
72
84
|
type BusinessType = "BEAUTY";
|
|
@@ -919,7 +931,7 @@ declare class ZalkeraError extends Error {
|
|
|
919
931
|
* client 발행 전 `scripts/sync-section-contract.mjs` 로 대조한다.
|
|
920
932
|
*/
|
|
921
933
|
/** 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다. */
|
|
922
|
-
declare const SECTION_CONTRACT_REV =
|
|
934
|
+
declare const SECTION_CONTRACT_REV = 2;
|
|
923
935
|
/** 업종 분류 — 콘솔 섹션 픽커의 그룹핑에 쓴다(테넌트 업종 필드는 아직 없다·memo102 §5). */
|
|
924
936
|
type SectionVertical = "BEAUTY" | "GENERAL";
|
|
925
937
|
interface SectionSpec {
|
|
@@ -935,7 +947,7 @@ interface SectionSpec {
|
|
|
935
947
|
declare const SECTION_CONTRACT: readonly [{
|
|
936
948
|
readonly type: "SERVICE_MENU";
|
|
937
949
|
readonly vertical: "BEAUTY";
|
|
938
|
-
readonly jsonLd:
|
|
950
|
+
readonly jsonLd: "ItemList";
|
|
939
951
|
}, {
|
|
940
952
|
readonly type: "BEFORE_AFTER_GALLERY";
|
|
941
953
|
readonly vertical: "BEAUTY";
|
|
@@ -1012,7 +1024,13 @@ declare function asObjectArray(value: unknown): Record<string, unknown>[];
|
|
|
1012
1024
|
declare function asString(value: unknown): string | undefined;
|
|
1013
1025
|
/** 양의 정수가 아니면 undefined. */
|
|
1014
1026
|
declare function asId(value: unknown): number | undefined;
|
|
1015
|
-
/**
|
|
1027
|
+
/**
|
|
1028
|
+
* 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다).
|
|
1029
|
+
*
|
|
1030
|
+
* 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config
|
|
1031
|
+
* 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.
|
|
1032
|
+
* **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 인코딩만 하고 값을 판정하지 않는다.
|
|
1033
|
+
*/
|
|
1016
1034
|
declare function mediaSrc(assetId: number): string;
|
|
1017
1035
|
|
|
1018
1036
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -67,6 +67,18 @@ interface SiteConfig {
|
|
|
67
67
|
* 정책 문구와 이 값이 갈리지 않도록 금액은 항상 이 필드를 쓴다.
|
|
68
68
|
*/
|
|
69
69
|
defaultReturnShippingFee: number | null;
|
|
70
|
+
/**
|
|
71
|
+
* 영업시간 JSON 문자열(스키마리스 — 테넌트가 채운다). 미설정이면 null.
|
|
72
|
+
* 권장 구조: `{weekly:[{dayOfWeek:["Monday",…], opens:"10:00", closes:"20:00"}], closedNote?:string}`.
|
|
73
|
+
*
|
|
74
|
+
* `dayOfWeek`·`opens`·`closes` 는 schema.org `OpeningHoursSpecification` 의 속성명을 그대로 쓴다 —
|
|
75
|
+
* 중간 어휘를 하나 더 만들면 매핑표가 사본이 되고, 사본은 갈라진다.
|
|
76
|
+
*
|
|
77
|
+
* **null 이면 JSON-LD 에서 필드를 통째로 빼라.** 영업시간을 모르는 채 "영업시간 있음"만 주장하는 것은
|
|
78
|
+
* 구조화 데이터로서 거짓이고, `LocalBusiness` 는 그 거짓이 곧바로 사람의 헛걸음이 되는 타입이다.
|
|
79
|
+
* **파싱 실패해도 죽으면 안 된다** — 패스스루라 어떤 값이든 올 수 있다.
|
|
80
|
+
*/
|
|
81
|
+
openingHours: string | null;
|
|
70
82
|
}
|
|
71
83
|
/** 테넌트 업종. 서버가 값을 추가할 수 있다 — 모르는 값은 일반(Organization)으로 흘려보내라. */
|
|
72
84
|
type BusinessType = "BEAUTY";
|
|
@@ -919,7 +931,7 @@ declare class ZalkeraError extends Error {
|
|
|
919
931
|
* client 발행 전 `scripts/sync-section-contract.mjs` 로 대조한다.
|
|
920
932
|
*/
|
|
921
933
|
/** 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다. */
|
|
922
|
-
declare const SECTION_CONTRACT_REV =
|
|
934
|
+
declare const SECTION_CONTRACT_REV = 2;
|
|
923
935
|
/** 업종 분류 — 콘솔 섹션 픽커의 그룹핑에 쓴다(테넌트 업종 필드는 아직 없다·memo102 §5). */
|
|
924
936
|
type SectionVertical = "BEAUTY" | "GENERAL";
|
|
925
937
|
interface SectionSpec {
|
|
@@ -935,7 +947,7 @@ interface SectionSpec {
|
|
|
935
947
|
declare const SECTION_CONTRACT: readonly [{
|
|
936
948
|
readonly type: "SERVICE_MENU";
|
|
937
949
|
readonly vertical: "BEAUTY";
|
|
938
|
-
readonly jsonLd:
|
|
950
|
+
readonly jsonLd: "ItemList";
|
|
939
951
|
}, {
|
|
940
952
|
readonly type: "BEFORE_AFTER_GALLERY";
|
|
941
953
|
readonly vertical: "BEAUTY";
|
|
@@ -1012,7 +1024,13 @@ declare function asObjectArray(value: unknown): Record<string, unknown>[];
|
|
|
1012
1024
|
declare function asString(value: unknown): string | undefined;
|
|
1013
1025
|
/** 양의 정수가 아니면 undefined. */
|
|
1014
1026
|
declare function asId(value: unknown): number | undefined;
|
|
1015
|
-
/**
|
|
1027
|
+
/**
|
|
1028
|
+
* 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다).
|
|
1029
|
+
*
|
|
1030
|
+
* 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config
|
|
1031
|
+
* 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.
|
|
1032
|
+
* **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 인코딩만 하고 값을 판정하지 않는다.
|
|
1033
|
+
*/
|
|
1016
1034
|
declare function mediaSrc(assetId: number): string;
|
|
1017
1035
|
|
|
1018
1036
|
/**
|
package/dist/index.js
CHANGED
|
@@ -67,6 +67,11 @@ function isApiErrorBody(value) {
|
|
|
67
67
|
return typeof value === "object" && value !== null && "message" in value && typeof value.message === "string";
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// src/pathSegment.ts
|
|
71
|
+
function seg(v) {
|
|
72
|
+
return encodeURIComponent(String(v));
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
// src/client.ts
|
|
71
76
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
72
77
|
function createZalkeraClient(options) {
|
|
@@ -79,6 +84,9 @@ function createZalkeraClient(options) {
|
|
|
79
84
|
}
|
|
80
85
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
81
86
|
async function request(path, init) {
|
|
87
|
+
if (/(^|\/)\.{1,2}(\/|$)/.test(path)) {
|
|
88
|
+
throw new ZalkeraError(`\uACBD\uB85C\uC5D0 \uC0C1\uB300 \uC138\uADF8\uBA3C\uD2B8\uAC00 \uC788\uC2B5\uB2C8\uB2E4: ${path}`, { status: 400, code: "INVALID_PATH" });
|
|
89
|
+
}
|
|
82
90
|
const url = new URL(`${baseUrl}/api${path}`);
|
|
83
91
|
if (init?.query) {
|
|
84
92
|
for (const [key, value] of Object.entries(init.query)) {
|
|
@@ -104,7 +112,14 @@ function createZalkeraClient(options) {
|
|
|
104
112
|
method: init?.method ?? "GET",
|
|
105
113
|
headers,
|
|
106
114
|
body: init?.body != null ? JSON.stringify(init.body) : void 0,
|
|
107
|
-
signal: controller.signal
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
// 리다이렉트를 따라가지 않는다 — **보안 불변식이라 스위치를 두지 않는다**(옵션으로 열면
|
|
117
|
+
// 그 옵션이 곧 사고 경로가 된다). 기본값 `follow` 로 크로스오리진 3xx 를 따라가면
|
|
118
|
+
// `X-Storefront-Key`(테넌트 시크릿)·`X-Tenant`·`X-Cart-Session`·`Idempotency-Key` 가
|
|
119
|
+
// 제3자 오리진에 그대로 전달된다 — undici 는 커스텀 헤더를 벗기지 않는다(실측).
|
|
120
|
+
// `"error"` 를 쓰지 않는 이유: undici 에서 일반 `TypeError: fetch failed` 로 떨어져
|
|
121
|
+
// 아래 네트워크 분기에 흡수되고, 그러면 원인(대개 baseUrl 오배선)이 사라진다.
|
|
122
|
+
redirect: "manual"
|
|
108
123
|
};
|
|
109
124
|
if (init?.next) fetchInit.next = init.next;
|
|
110
125
|
response = await fetchImpl(url.toString(), fetchInit);
|
|
@@ -117,6 +132,14 @@ function createZalkeraClient(options) {
|
|
|
117
132
|
} finally {
|
|
118
133
|
clearTimeout(timer);
|
|
119
134
|
}
|
|
135
|
+
if (isRedirectResponse(response)) {
|
|
136
|
+
response.body?.cancel().catch(() => {
|
|
137
|
+
});
|
|
138
|
+
throw new ZalkeraError(
|
|
139
|
+
`\uBC31\uC5D4\uB4DC\uAC00 \uB9AC\uB2E4\uC774\uB809\uD2B8\uB85C \uC751\uB2F5\uD588\uC2B5\uB2C8\uB2E4 (HTTP ${response.status}). \uBCF4\uC548\uC0C1 \uB530\uB77C\uAC00\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4 \u2014 \uB530\uB77C\uAC00\uBA74 \uC694\uCCAD \uD5E4\uB354(\uD14C\uB10C\uD2B8 \uC2DC\uD06C\uB9BF \uD0A4\xB7\uC7A5\uBC14\uAD6C\uB2C8 \uC138\uC158)\uAC00 \uB2E4\uB978 \uC624\uB9AC\uC9C4\uC73C\uB85C \uC0D9\uB2C8\uB2E4. baseUrl \uC774 \uCD5C\uC885 \uC624\uB9AC\uC9C4\uC778\uC9C0 \uD655\uC778\uD558\uC138\uC694(http\u2194https \uC2B9\uACA9, www \uC720\uBB34, \uD504\uB85D\uC2DC\uC758 \uACBD\uB85C \uB9AC\uB77C\uC774\uD2B8).`,
|
|
140
|
+
{ status: 502, code: "UPSTREAM_REDIRECT" }
|
|
141
|
+
);
|
|
142
|
+
}
|
|
120
143
|
const text = await response.text();
|
|
121
144
|
const parsed = text ? safeJsonParse(text) : null;
|
|
122
145
|
if (!response.ok) {
|
|
@@ -142,18 +165,18 @@ function createZalkeraClient(options) {
|
|
|
142
165
|
sort: params?.sort
|
|
143
166
|
}
|
|
144
167
|
}),
|
|
145
|
-
getPost: (slug) => request(`/public/posts/${
|
|
146
|
-
recordPostView: (slug, context) => request(`/public/posts/${
|
|
168
|
+
getPost: (slug) => request(`/public/posts/${seg(slug)}`),
|
|
169
|
+
recordPostView: (slug, context) => request(`/public/posts/${seg(slug)}/view`, {
|
|
147
170
|
method: "POST",
|
|
148
171
|
context
|
|
149
172
|
}),
|
|
150
|
-
getPage: (slug, options2) => request(`/public/pages/${
|
|
173
|
+
getPage: (slug, options2) => request(`/public/pages/${seg(slug)}`, nextInit(options2)),
|
|
151
174
|
listPages: (params, options2) => request("/public/pages", {
|
|
152
175
|
query: { page: params?.page, size: params?.size },
|
|
153
176
|
...nextInit(options2)
|
|
154
177
|
}),
|
|
155
178
|
listMenus: (options2) => request("/public/menus", { next: options2?.tags ? { tags: options2.tags } : void 0 }),
|
|
156
|
-
getMediaUrl: (id) => request(`/public/media/${id}/url`),
|
|
179
|
+
getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
|
|
157
180
|
submitInquiry: (input, context) => request("/public/inquiries", {
|
|
158
181
|
method: "POST",
|
|
159
182
|
body: input,
|
|
@@ -165,7 +188,7 @@ function createZalkeraClient(options) {
|
|
|
165
188
|
context
|
|
166
189
|
}),
|
|
167
190
|
// ── 커머스: 카탈로그 ────────────────────────────────────
|
|
168
|
-
getProduct: (slug, options2) => request(`/public/products/${
|
|
191
|
+
getProduct: (slug, options2) => request(`/public/products/${seg(slug)}`, nextInit(options2)),
|
|
169
192
|
listProducts: (params, options2) => request("/public/products", {
|
|
170
193
|
query: {
|
|
171
194
|
productType: params?.productType,
|
|
@@ -178,14 +201,14 @@ function createZalkeraClient(options) {
|
|
|
178
201
|
}),
|
|
179
202
|
listProductCategories: (options2) => request("/public/product-categories", nextInit(options2)),
|
|
180
203
|
// ── 커머스: 상품후기(공개) ──────────────────────────────
|
|
181
|
-
listProductReviews: (productId, params, options2) => request(`/public/products/${productId}/reviews`, {
|
|
204
|
+
listProductReviews: (productId, params, options2) => request(`/public/products/${seg(productId)}/reviews`, {
|
|
182
205
|
query: { page: params?.page, size: params?.size },
|
|
183
206
|
next: options2?.tags ? { tags: options2.tags } : void 0
|
|
184
207
|
}),
|
|
185
|
-
getProductReviewSummary: (productId, options2) => request(`/public/products/${productId}/reviews/summary`, {
|
|
208
|
+
getProductReviewSummary: (productId, options2) => request(`/public/products/${seg(productId)}/reviews/summary`, {
|
|
186
209
|
next: options2?.tags ? { tags: options2.tags } : void 0
|
|
187
210
|
}),
|
|
188
|
-
createProductReview: (productId, input, accessToken) => request(`/shop/products/${productId}/reviews`, { method: "POST", body: input, bearer: accessToken }),
|
|
211
|
+
createProductReview: (productId, input, accessToken) => request(`/shop/products/${seg(productId)}/reviews`, { method: "POST", body: input, bearer: accessToken }),
|
|
189
212
|
// ── 커머스: 고객 인증 ───────────────────────────────────
|
|
190
213
|
socialLogin: (input) => request("/shop/auth/social", { method: "POST", body: input }),
|
|
191
214
|
refreshSession: (refreshToken) => request("/shop/auth/refresh", { method: "POST", body: { refreshToken } }),
|
|
@@ -200,11 +223,11 @@ function createZalkeraClient(options) {
|
|
|
200
223
|
}),
|
|
201
224
|
createBooking: (accessToken, input) => request("/shop/booking/bookings", { method: "POST", body: input, bearer: accessToken }),
|
|
202
225
|
myBookings: (accessToken) => request("/shop/booking/bookings", { bearer: accessToken }),
|
|
203
|
-
cancelBooking: (accessToken, bookingCode) => request(`/shop/booking/bookings/${
|
|
226
|
+
cancelBooking: (accessToken, bookingCode) => request(`/shop/booking/bookings/${seg(bookingCode)}/cancel`, {
|
|
204
227
|
method: "POST",
|
|
205
228
|
bearer: accessToken
|
|
206
229
|
}),
|
|
207
|
-
rescheduleBooking: (accessToken, bookingCode, newSlotId) => request(`/shop/booking/bookings/${
|
|
230
|
+
rescheduleBooking: (accessToken, bookingCode, newSlotId) => request(`/shop/booking/bookings/${seg(bookingCode)}/reschedule`, {
|
|
208
231
|
method: "POST",
|
|
209
232
|
body: { newSlotId },
|
|
210
233
|
bearer: accessToken
|
|
@@ -212,33 +235,33 @@ function createZalkeraClient(options) {
|
|
|
212
235
|
// ── 커머스: 장바구니 ────────────────────────────────────
|
|
213
236
|
getCart: (session) => request("/shop/cart", shopInit(session)),
|
|
214
237
|
addToCart: (variantId, quantity, session) => request("/shop/cart/items", shopInit(session, { method: "POST", body: { variantId, quantity } })),
|
|
215
|
-
updateCartItem: (variantId, quantity, session) => request(`/shop/cart/items/${variantId}`, shopInit(session, { method: "PATCH", body: { quantity } })),
|
|
216
|
-
removeFromCart: (variantId, session) => request(`/shop/cart/items/${variantId}`, shopInit(session, { method: "DELETE" })),
|
|
238
|
+
updateCartItem: (variantId, quantity, session) => request(`/shop/cart/items/${seg(variantId)}`, shopInit(session, { method: "PATCH", body: { quantity } })),
|
|
239
|
+
removeFromCart: (variantId, session) => request(`/shop/cart/items/${seg(variantId)}`, shopInit(session, { method: "DELETE" })),
|
|
217
240
|
clearCart: (session) => request("/shop/cart", shopInit(session, { method: "DELETE" })),
|
|
218
241
|
// ── 커머스: 결제·주문·배송 ──────────────────────────────
|
|
219
242
|
checkout: (input, session, idempotencyKey) => request("/shop/checkout", shopInit(session, { method: "POST", body: input, idempotencyKey })),
|
|
220
243
|
confirmPayment: (orderNo, providerParams, access) => request(
|
|
221
|
-
`/shop/orders/${
|
|
244
|
+
`/shop/orders/${seg(orderNo)}/payment/confirm`,
|
|
222
245
|
accessInit(access, { method: "POST", body: providerParams })
|
|
223
246
|
),
|
|
224
247
|
startPayment: (orderNo, access) => request(
|
|
225
|
-
`/shop/orders/${
|
|
248
|
+
`/shop/orders/${seg(orderNo)}/payment/session`,
|
|
226
249
|
accessInit(access, { method: "POST" })
|
|
227
250
|
),
|
|
228
|
-
getOrder: (orderNo, access) => request(`/shop/orders/${
|
|
251
|
+
getOrder: (orderNo, access) => request(`/shop/orders/${seg(orderNo)}`, accessInit(access)),
|
|
229
252
|
listMyOrders: (accessToken, params) => request("/shop/orders", {
|
|
230
253
|
bearer: accessToken,
|
|
231
254
|
query: { page: params?.page, size: params?.size }
|
|
232
255
|
}),
|
|
233
256
|
cancelOrder: (orderNo, access) => request(
|
|
234
|
-
`/shop/orders/${
|
|
257
|
+
`/shop/orders/${seg(orderNo)}/cancel`,
|
|
235
258
|
accessInit(access, { method: "POST" })
|
|
236
259
|
),
|
|
237
260
|
completeOrder: (orderNo, access) => request(
|
|
238
|
-
`/shop/orders/${
|
|
261
|
+
`/shop/orders/${seg(orderNo)}/complete`,
|
|
239
262
|
accessInit(access, { method: "POST" })
|
|
240
263
|
),
|
|
241
|
-
getShipment: (orderNo, access) => request(`/shop/orders/${
|
|
264
|
+
getShipment: (orderNo, access) => request(`/shop/orders/${seg(orderNo)}/shipment`, accessInit(access))
|
|
242
265
|
};
|
|
243
266
|
}
|
|
244
267
|
function nextInit(options) {
|
|
@@ -254,6 +277,9 @@ function accessInit(access, extra) {
|
|
|
254
277
|
query: access.phone ? { phone: access.phone } : void 0
|
|
255
278
|
};
|
|
256
279
|
}
|
|
280
|
+
function isRedirectResponse(response) {
|
|
281
|
+
return response.type === "opaqueredirect" || response.status >= 300 && response.status < 400;
|
|
282
|
+
}
|
|
257
283
|
function safeJsonParse(text) {
|
|
258
284
|
try {
|
|
259
285
|
return JSON.parse(text);
|
|
@@ -263,10 +289,13 @@ function safeJsonParse(text) {
|
|
|
263
289
|
}
|
|
264
290
|
|
|
265
291
|
// src/sections.ts
|
|
266
|
-
var SECTION_CONTRACT_REV =
|
|
292
|
+
var SECTION_CONTRACT_REV = 2;
|
|
267
293
|
var SECTION_CONTRACT = [
|
|
268
294
|
// 뷰티(memo47) — append-only 계약상 불변
|
|
269
|
-
|
|
295
|
+
// SERVICE_MENU 의 ItemList 는 rev 2 에서 올라왔다(memo119 T6-ⓒ): 쇼핑몰 유형엔 상품 목록 표면을
|
|
296
|
+
// 요구하면서 예약 유형엔 시술 목록 표면이 없던 비대칭의 해소다. 뷰티 사이트에서 시술 목록의
|
|
297
|
+
// 정위치는 별도 라우트가 아니라 홈의 이 섹션이라, FAQ_LIST→FAQPage 와 같은 형태로 섹션이 직접 낸다.
|
|
298
|
+
{ type: "SERVICE_MENU", vertical: "BEAUTY", jsonLd: "ItemList" },
|
|
270
299
|
{ type: "BEFORE_AFTER_GALLERY", vertical: "BEAUTY", jsonLd: null },
|
|
271
300
|
{ type: "BOOKING_CTA", vertical: "BEAUTY", jsonLd: null },
|
|
272
301
|
{ type: "DOCTOR_INTRO", vertical: "BEAUTY", jsonLd: null },
|
|
@@ -325,7 +354,7 @@ function asId(value) {
|
|
|
325
354
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
|
|
326
355
|
}
|
|
327
356
|
function mediaSrc(assetId) {
|
|
328
|
-
return `/media/${assetId}`;
|
|
357
|
+
return `/media/${seg(assetId)}`;
|
|
329
358
|
}
|
|
330
359
|
|
|
331
360
|
// src/theme.ts
|