@zalkera/client 0.13.1 → 0.15.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 +1 -1
- package/contracts/aeo-surface-guarantees.json +3 -5
- package/dist/index.cjs +13 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -3
- package/dist/index.d.ts +26 -3
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/llms.txt +334 -40
- package/package.json +57 -57
package/llms.txt
CHANGED
|
@@ -72,7 +72,17 @@ await zalkera.submitInquiry(input, { clientIp: ip });
|
|
|
72
72
|
- `getProduct(slug)` → `ProductDetail { id, slug, name, description, productType, coverAssetId, seo, variants[] }`
|
|
73
73
|
- `seo` 는 **JSON 문자열 패스스루**(백엔드가 검증 안 함·미설정이면 null). 권장 구조는 `{"title","description"}` —
|
|
74
74
|
`SiteConfig.seoDefaults` 와 같은 모양이다. **파싱 실패해도 죽으면 안 된다**(`parseConfig` 로 읽고, 없으면 상품명으로 강하).
|
|
75
|
-
- `listProductCategories()` → `ProductCategory[]`
|
|
75
|
+
- `listProductCategories()` → `ProductCategory[]` — **전량 반환**(테넌트당 수십 건 규모라 페이지네이션이 없다).
|
|
76
|
+
`ProductCategory { id, parentId, slug, name, sortOrder }`
|
|
77
|
+
- **`sortOrder` 오름차순, 동률이면 `id` 오름차순으로 정렬해 준다** — 받아서 다시 정렬할 필요가 없다.
|
|
78
|
+
- `slug` 가 **참조 정본**이다. URL 정체성(`/c/{slug}`)이고, 테넌트를 옮겨도 뜻이 유지되는 자연키다.
|
|
79
|
+
- `parentId` 는 계층이다. 보장 표면은 **평면 사용을 전제**하고 트리 렌더는 자유다.
|
|
80
|
+
- **단건 조회 API 는 없다** — slug 로 찾을 때는 이 목록에서 고른다.
|
|
81
|
+
- **좁히는 축은 id 다.** `listProducts({categoryId})` 로 갈래별 목록을 받는다 — 목록 API 는 slug 를 안 받는다.
|
|
82
|
+
slug → id 해소는 위 목록에서 한다. 없는 `categoryId` 는 **빈 목록이지 404 가 아니다**(존재 판정은 카테고리 목록의 몫).
|
|
83
|
+
- ⚠ **소스에 숫자 id 를 적지 마라.** 업무 데이터를 가리키는 키는 `slug`/`handle` 뿐이다 — 숫자 id 는 테넌트마다
|
|
84
|
+
다른 값이라, 소스에 적는 순간 그 소스는 다른 테넌트로 옮겨갈 수 없다. 외양(소스)은 어디서든 구해 와서
|
|
85
|
+
올리는 물건이라는 것이 이 제품의 전제이고, 그 전제가 이 규칙을 요구한다.
|
|
76
86
|
- **variant 가 판매 단위다.** 가격·재고·장바구니·주문은 전부 `variant.id` 기준. 단순 상품도 variant 1개.
|
|
77
87
|
`ProductVariant { id, sku, optionSignature, price, compareAtPrice, currency, inStock, available }`
|
|
78
88
|
- `optionSignature` 는 옵션 조합 표시용 문자열("레드/XL") — 단순 상품은 **빈 문자열**이라 그때는 안 그린다.
|
|
@@ -147,7 +157,7 @@ await zalkera.submitInquiry(input, { clientIp: ip });
|
|
|
147
157
|
빈 배열은 truthy 라 `if (items)` 로 결측을 판별하면 안 된다 — `items.length` 을 봐라.
|
|
148
158
|
- 위 가드로 **필수 값이 없으면 그 섹션만 안 그린다**. 페이지 전체가 죽으면 안 된다.
|
|
149
159
|
- `mediaSrc(assetId)` → `/media/{id}` **경로 문자열**. 이미지는 전부 이걸 통한다.
|
|
150
|
-
전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(
|
|
160
|
+
전제: 프로젝트에 `app/media/[id]/route.ts` 프록시 라우트가 있어야 한다(**§4.9 에 전문**).
|
|
151
161
|
이 헬퍼는 경로만 만든다 — 라우트를 안 만들면 이미지가 404 다.
|
|
152
162
|
- `asHandle(v)` / `asHandleArray(v)` / `assetPath(v)` — **참조 방언**(계약 `contractRev` 4 의 `dialects`)을 읽는다.
|
|
153
163
|
숫자 id 를 못 쓰는 자리에서 상품은 `handle`(= `ProductSummary.slug`), 에셋은 레포 `public/` **루트 절대 경로**로
|
|
@@ -239,7 +249,7 @@ export async function POST(req: Request) {
|
|
|
239
249
|
if (session.widget) {
|
|
240
250
|
// 위젯형(토스) — 내 사이트에서 결제창을 띄우고, 성공 콜백 파라미터를 confirm 으로 넘긴다
|
|
241
251
|
// widget = { vendor:"TOSS_PAYMENTS", clientKey, orderId, amount, orderName }
|
|
242
|
-
// →
|
|
252
|
+
// → 결제창 페이지에서 openWidget, 성공 리다이렉트 페이지에서 confirm (이 절의 흐름 그대로)
|
|
243
253
|
await zalkera.confirmPayment(orderNo, { paymentKey }, access); // BFF 경유
|
|
244
254
|
} else {
|
|
245
255
|
redirect(session.paymentUrl); // 리다이렉트형(PG 결제창으로 이동)
|
|
@@ -263,7 +273,8 @@ export async function POST(req: Request) {
|
|
|
263
273
|
>
|
|
264
274
|
> 관용구는 소비자에 따라 둘이다:
|
|
265
275
|
> - **브라우저(쿠키 있음)** = `` `co-${session.cartSessionKey}` `` **+ 체크아웃 성공 응답에서 카트 쿠키 회전**
|
|
266
|
-
> (
|
|
276
|
+
> (체크아웃 BFF 응답에 `response.cookies.set(CART_COOKIE, randomUUID(), {httpOnly:true, sameSite:"lax",
|
|
277
|
+
> secure, path:"/", maxAge: 30일})`). **회전 없이 카트키를 멱등키로 쓰지 마라** — 카트 쿠키는 30일이라
|
|
267
278
|
> 회전이 없으면 위의 "영구 재사용"이 그대로 실현된다(실측 사고). 회전을 응답에 실으면 Set-Cookie 가
|
|
268
279
|
> 성공 도달과 원자적으로 묶여서, 응답 유실 시엔 옛 키가 살아남아 재시도가 원주문을 재생한다 —
|
|
269
280
|
> **인메모리 "시도당 키"보다 이 쪽이 안전하다**(새로고침에 안 죽는다).
|
|
@@ -298,8 +309,8 @@ const shipment = await zalkera.getShipment(orderNo, { phone }); // 배송 상태
|
|
|
298
309
|
// 1) 슬롯 보여주기 — 공개(비로그인 가능).
|
|
299
310
|
// ⚠️ 이 RSC 직독 샘플은 **요청마다 렌더되는 동적 라우트에서만** 신선하다. 상품 상세가 ISR
|
|
300
311
|
// (force-static + revalidate=N)이면 슬롯이 프리렌더에 구워져 N초 낡은 시간표를 보여주고
|
|
301
|
-
// SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(
|
|
302
|
-
//
|
|
312
|
+
// SLOT_FULL 을 만든다 — 그땐 **클라이언트 아일랜드 + BFF 프록시**로 내려라(예약 패널을
|
|
313
|
+
// "use client" 로 두고 /api/booking/availability 가 대신 조회. 볼라틸한 데이터는 아일랜드로).
|
|
303
314
|
const slots = await zalkera.availability({
|
|
304
315
|
productId: product.id, // slug 아님 — getProduct(slug).id
|
|
305
316
|
from: new Date().toISOString(),
|
|
@@ -346,7 +357,6 @@ if (booking.orderNo) { // status=PENDING
|
|
|
346
357
|
깨므로 금지(하나라도 있으면 `tracking` 을 채우고, 전무면 undefined).
|
|
347
358
|
- **BFF 필수**: 브라우저에서 `submitLead` 직호출 금지(§2). route handler 에서 `x-forwarded-for` 첫
|
|
348
359
|
홉을 `{ clientIp }` 로 넘긴다(안 넘기면 방문자 전원 429 — §4.6·문의와 동일 관용구).
|
|
349
|
-
- 선례: `components/LeadForm.tsx`(아일랜드·UTM 캡처) · `app/api/lead/route.ts`(BFF·201 관통).
|
|
350
360
|
|
|
351
361
|
### 4.8 콘텐츠 파일로 그리는 고정 페이지 (`"content": "source"` · 권장)
|
|
352
362
|
|
|
@@ -412,6 +422,208 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
412
422
|
- 홈(`app/page.tsx`)은 `loadPageContent("home")` 의 섹션이 있으면 그것을 그리고, 없으면 커머스 골격으로
|
|
413
423
|
강하한다. 콘텐츠 없는 것은 **정상**이다(커머스 테넌트).
|
|
414
424
|
|
|
425
|
+
### 4.9 미디어 프록시 라우트 (`/media/{id}`) — **필수 부품**
|
|
426
|
+
|
|
427
|
+
`mediaSrc(assetId)` 는 `/media/{id}` **경로 문자열만** 만든다. 그 경로를 받는 라우트는 프로젝트가 갖는다.
|
|
428
|
+
**안 만들면 사이트의 이미지가 전부 404 다.** 아래를 `app/media/[id]/route.ts` 로 그대로 복사해라.
|
|
429
|
+
|
|
430
|
+
왜 프록시가 필요한가 — 백엔드 공개 미디어는 둘 다 브라우저·크롤러가 직접 못 쓴다:
|
|
431
|
+
- `/api/public/media/{id}/url` → **수 분 뒤 만료되는 presigned URL**. ISR 로 캐시된 HTML 안에서 죽고,
|
|
432
|
+
JSON-LD 에 넣으면 크롤러가 캐시한 뒤 깨진 이미지가 된다.
|
|
433
|
+
- `/api/public/media/{id}/raw` → 안정 URL 이지만 **`X-Tenant` 헤더를 요구**한다(없으면 400). 크롤러도
|
|
434
|
+
`<img>` 도 그 헤더를 못 보낸다.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import {NextResponse} from "next/server";
|
|
438
|
+
|
|
439
|
+
export async function GET(_req: Request, {params}: {params: Promise<{id: string}>}) {
|
|
440
|
+
const {id} = await params;
|
|
441
|
+
// id 를 URL 에 이어붙이므로 숫자만 통과시킨다(경로 주입 차단).
|
|
442
|
+
if (!/^\d+$/.test(id)) return new NextResponse(null, {status: 404});
|
|
443
|
+
|
|
444
|
+
// **미설정이면 여기서 죽는다.** 빈 헤더로 백엔드를 부르면 임의 테넌트에 조용히 붙거나
|
|
445
|
+
// 전 이미지가 400 이 되는데, 둘 다 원인이 안 보인다.
|
|
446
|
+
const base = process.env.ZALKERA_API_BASE;
|
|
447
|
+
const tenant = process.env.ZALKERA_TENANT;
|
|
448
|
+
if (!base || !tenant) throw new Error("ZALKERA_API_BASE·ZALKERA_TENANT 미설정");
|
|
449
|
+
|
|
450
|
+
let res: Response;
|
|
451
|
+
try {
|
|
452
|
+
res = await fetch(`${base}/api/public/media/${id}/raw`, {
|
|
453
|
+
headers: {"X-Tenant": tenant},
|
|
454
|
+
// 302 를 따라가면 이미지 바이트가 이 런타임을 통과한다 — Location 만 필요하다.
|
|
455
|
+
redirect: "manual",
|
|
456
|
+
cache: "no-store",
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
return new NextResponse(null, {status: 502});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const location = res.headers.get("location");
|
|
463
|
+
// 302+Location 이 없으면(없는 id·타 테넌트 id) 그대로 없는 것으로 취급한다.
|
|
464
|
+
if (!location) return new NextResponse(null, {status: res.status === 404 ? 404 : 502});
|
|
465
|
+
|
|
466
|
+
return NextResponse.redirect(location, {status: 302, headers: {"Cache-Control": "no-store"}});
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
- **바이트를 스트리밍하지 마라.** 그러면 모든 이미지 트래픽이 Next 런타임을 통과해 밀도 비용이 붙는다.
|
|
471
|
+
바이트는 스토리지→브라우저 직행하고, 이미지뷰당 서명 요청 1회만 낸다.
|
|
472
|
+
- **`no-store` 를 지켜라** — 302 대상이 곧 만료되므로 이 응답을 캐시하면 죽은 링크를 재사용한다
|
|
473
|
+
(이미지 바이트는 스토리지 응답 헤더로 브라우저가 캐시한다).
|
|
474
|
+
|
|
475
|
+
### 4.10 JSON-LD 부품 — **그대로 복사해서 쓴다**
|
|
476
|
+
|
|
477
|
+
§5.1 이 요구하는 그래프를 만드는 부품이다. 재발명하지 마라 — 아래가 정본이고, 지어낸 필드·빈 값은
|
|
478
|
+
구조화 데이터 정책 위반(리치결과 박탈)이다. **값이 없으면 필드를 통째로 뺀다**(널·0 을 넣지 않는다).
|
|
479
|
+
|
|
480
|
+
삽입 컴포넌트(서버 컴포넌트로 유지 — `"use client"` 금지):
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
export function JsonLd({data}: {data: object}) {
|
|
484
|
+
return (
|
|
485
|
+
<script
|
|
486
|
+
type="application/ld+json"
|
|
487
|
+
// `<` 를 유니코드 이스케이프 — 데이터에 `</script>` 가 섞여도 스크립트가 조기 종료되지
|
|
488
|
+
// 않는다(JSON-LD 삽입의 고전적 XSS 벡터). JSON.stringify 는 이걸 해주지 않는다.
|
|
489
|
+
dangerouslySetInnerHTML={{__html: JSON.stringify(data).replace(/</g, "\\u003c")}}
|
|
490
|
+
/>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
빌더 — 전부 순수 함수다(평범한 객체를 반환한다):
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
import type {PostDetail, ProductDetail, RatingSummary, SiteConfig} from "@zalkera/client";
|
|
499
|
+
|
|
500
|
+
/** 상품 상세. **variant 마다 Offer 하나** — 우리는 항상 variant 단위로 판다(옵션 없는 상품도 default 1개). */
|
|
501
|
+
export function productJsonLd(product: ProductDetail, rating?: RatingSummary | null, siteBase = "", returnPolicy?: object | null) {
|
|
502
|
+
const url = `${siteBase}/products/${product.slug}`;
|
|
503
|
+
const offers = product.variants.map((v) => ({
|
|
504
|
+
"@type": "Offer", url, price: v.price, priceCurrency: v.currency,
|
|
505
|
+
availability: v.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
|
506
|
+
...(v.sku ? {sku: v.sku} : {}),
|
|
507
|
+
...(v.optionSignature ? {name: v.optionSignature} : {}), // 단순 상품은 빈 문자열 — 이름을 안 붙인다
|
|
508
|
+
...(returnPolicy ? {hasMerchantReturnPolicy: returnPolicy} : {}),
|
|
509
|
+
}));
|
|
510
|
+
return {
|
|
511
|
+
"@context": "https://schema.org", "@type": "Product", name: product.name, url,
|
|
512
|
+
// 페이지가 그리는 그 이미지 — **안정 URL(`/media/{id}`)**. presigned 는 만료돼 크롤러 캐시가 죽는다.
|
|
513
|
+
...(product.coverAssetId != null ? {image: [`${siteBase}/media/${product.coverAssetId}`]} : {}),
|
|
514
|
+
...(product.description ? {description: product.description} : {}),
|
|
515
|
+
...(offers.length > 0 ? {offers} : {}),
|
|
516
|
+
// 후기 0건이면 aggregateRating 자체를 뺀다 — ratingValue:0 은 "0점짜리 상품"이라는 거짓 진술이다.
|
|
517
|
+
...(rating && rating.reviewCount > 0
|
|
518
|
+
? {aggregateRating: {"@type": "AggregateRating", ratingValue: rating.averageRating, reviewCount: rating.reviewCount}}
|
|
519
|
+
: {}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 사이트 주체 — 홈에 1회. 업태는 **명시 입력값으로만** 좁힌다(테마 선택은 업태 진술이 아니다). */
|
|
524
|
+
export function organizationJsonLd(config: SiteConfig, siteBase: string, type?: string) {
|
|
525
|
+
return {
|
|
526
|
+
"@context": "https://schema.org",
|
|
527
|
+
// 모르는 값·미설정은 Organization — 거짓 진술보다 덜 구체적인 진술이 낫다.
|
|
528
|
+
// 온라인 전용 몰에 LocalBusiness 를 붙이지 마라(주소·영업시간을 요구하는 타입이다).
|
|
529
|
+
"@type": type ?? (config.businessType === "BEAUTY" ? "BeautySalon" : "Organization"),
|
|
530
|
+
name: config.companyName, url: siteBase,
|
|
531
|
+
...(config.tel ? {telephone: config.tel} : {}),
|
|
532
|
+
...(config.email ? {email: config.email} : {}),
|
|
533
|
+
...(config.address ? {address: {"@type": "PostalAddress", streetAddress: config.address, addressCountry: "KR"}} : {}),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 블로그·공지 상세. **author 를 넣지 마라** — 데이터에도 화면에도 없다(지어내면 위반). */
|
|
538
|
+
export function blogPostingJsonLd(post: PostDetail, siteBase: string) {
|
|
539
|
+
return {
|
|
540
|
+
"@context": "https://schema.org", "@type": "BlogPosting",
|
|
541
|
+
headline: post.title, url: `${siteBase}/blog/${post.slug}`,
|
|
542
|
+
...(post.publishedAt ? {datePublished: post.publishedAt} : {}),
|
|
543
|
+
...(post.summary ? {description: post.summary} : {}),
|
|
544
|
+
...(post.coverAssetId != null ? {image: [`${siteBase}/media/${post.coverAssetId}`]} : {}),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** 고정 페이지(회사소개·이용안내 등). 없으면 순수 마케팅 사이트는 인용될 노드가 홈 하나뿐이 된다. */
|
|
549
|
+
export function webPageJsonLd(page: {title: string; slug: string}, siteBase: string, description?: string) {
|
|
550
|
+
return {
|
|
551
|
+
"@context": "https://schema.org", "@type": "WebPage",
|
|
552
|
+
name: page.title, url: `${siteBase}/${page.slug}`, // name 은 페이지가 그리는 <h1> 과 같은 값
|
|
553
|
+
...(description ? {description} : {}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function itemListNode(items: Array<{name: string; url: string}>) {
|
|
558
|
+
return {
|
|
559
|
+
"@type": "ItemList",
|
|
560
|
+
// position 은 **화면에 보이는 그 순서** — 원장이 정한 노출 순서를 기계에도 같은 순서로 준다.
|
|
561
|
+
itemListElement: items.map((item, i) => ({"@type": "ListItem", position: i + 1, name: item.name, url: item.url})),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 목록 표면(상품 목록·시술 메뉴·글 목록). **요약형** — 각 항목은 url 로 상세를 가리키고 이름만 든다.
|
|
567
|
+
* 가격·재고를 여기 복제하지 마라: 같은 사실이 두 곳에 있으면 갈라지고 갈라진 쪽이 거짓이 된다.
|
|
568
|
+
* 가격의 정본은 상세의 `Offer` 다.
|
|
569
|
+
*/
|
|
570
|
+
export function itemListJsonLd(items: Array<{name: string; url: string}>) {
|
|
571
|
+
return {"@context": "https://schema.org", ...itemListNode(items)};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 카테고리 페이지 — `ItemList` 를 `mainEntity` 로 **품는다**(나란히 내면 "이 페이지가 곧 그 목록"이
|
|
576
|
+
* 안 전해진다). **상품 0건이어도 낸다** — 이 페이지의 주어가 이 카테고리 하나라 "지금 0건"이 참인
|
|
577
|
+
* 진술이기 때문이다(카탈로그 전체 목록에서 빈 ItemList 를 빼는 것과 갈리는 자리다).
|
|
578
|
+
*/
|
|
579
|
+
export function collectionPageJsonLd(category: {name: string; slug: string}, items: Array<{name: string; url: string}>, siteBase: string, description?: string) {
|
|
580
|
+
return {
|
|
581
|
+
"@context": "https://schema.org", "@type": "CollectionPage",
|
|
582
|
+
name: category.name, url: `${siteBase}/c/${category.slug}`,
|
|
583
|
+
...(description ? {description} : {}),
|
|
584
|
+
mainEntity: itemListNode(items), // 중첩 노드에 @context 를 다시 달지 않는다
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** 경로 이동 — 검색결과에 `홈 > 상품 > 이름` 으로 노출된다. items 는 표시순. */
|
|
589
|
+
export function breadcrumbJsonLd(items: Array<{name: string; url: string}>) {
|
|
590
|
+
return {
|
|
591
|
+
"@context": "https://schema.org", "@type": "BreadcrumbList",
|
|
592
|
+
itemListElement: items.map((item, i) => ({"@type": "ListItem", position: i + 1, name: item.name, item: item.url})),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
**환불 정책**(`MerchantReturnPolicy`)을 Offer 에 붙이면 구글이 "무료 반품·N일 이내"를 리치결과에 노출하고
|
|
598
|
+
AI 에이전트가 "이 가게 환불 되나"를 읽는다. 두 규율이 중요하다:
|
|
599
|
+
- **금액은 `config.defaultReturnShippingFee`(운영 값)** 를 쓴다 — 정책 문구가 아니라. 실제 차감액과
|
|
600
|
+
표시가 갈리면 표시 의무 위반이다.
|
|
601
|
+
- **기간(`windowDays`)이 없으면 정책 전체를 내지 않는다** — 구글이 `merchantReturnDays` 를 요구하고,
|
|
602
|
+
창구를 모르는 채 "반품 됨"만 주장하는 건 무의미하다.
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
export function merchantReturnPolicyJsonLd(config: SiteConfig, windowDays?: number) {
|
|
606
|
+
// 구글이 정수를 요구한다. 7.5·-1 을 흘리면 정책 노드 전체가 무효 판정된다.
|
|
607
|
+
if (windowDays == null || !Number.isInteger(windowDays) || windowDays < 0) return null;
|
|
608
|
+
const fee = config.defaultReturnShippingFee;
|
|
609
|
+
return {
|
|
610
|
+
"@type": "MerchantReturnPolicy", applicableCountry: "KR",
|
|
611
|
+
returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow",
|
|
612
|
+
merchantReturnDays: windowDays, returnMethod: "https://schema.org/ReturnByMail",
|
|
613
|
+
...(fee != null && fee > 0
|
|
614
|
+
? {returnFees: "https://schema.org/ReturnShippingFees",
|
|
615
|
+
returnShippingFeesAmount: {"@type": "MonetaryAmount", value: fee, currency: "KRW"}}
|
|
616
|
+
: {returnFees: "https://schema.org/FreeReturn"}), // 0원이면 무료 반품 — 그 사실이 노출된다
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
> 테넌트 커머스 정책은 **스키마리스 JSON** 이라 소비 쪽에서 방어적으로 읽어라. 최상위 한 겹만 보면
|
|
622
|
+
> 부족하다 — `{"returns":{"notes":{"ko":"…","en":"…"}}}` 같은 다국어 객체가 흔한 확장 모양인데, 그걸
|
|
623
|
+
> 그대로 React 자식으로 그리면 *"Objects are not valid as a React child"* 로 페이지가 500 이 된다.
|
|
624
|
+
> **필드별로 `typeof` 를 확인**하고 형에 안 맞으면 그 필드만 버려라(절 전체를 버리지 않는다).
|
|
625
|
+
> 정책은 부가 정보이므로 파싱 실패가 페이지를 죽이면 안 된다.
|
|
626
|
+
|
|
415
627
|
## 5. 흔한 실수(하지 말 것)
|
|
416
628
|
|
|
417
629
|
- ❌ 클라이언트 컴포넌트에서 `@zalkera/client` import → baseUrl 노출. ✅ 서버에서만.
|
|
@@ -444,7 +656,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
444
656
|
실시간·개인화 데이터(라이브 재고·개인화)는 **클라이언트 컴포넌트(아일랜드)**로 가져오고, 상태 변경
|
|
445
657
|
(장바구니·주문)은 **BFF route handler** 로 한다. 신선도는 **온디맨드 revalidate**(백엔드 데이터 변경 시
|
|
446
658
|
`POST /api/revalidate`)로 지킨다. 동적 SSR 이 꼭 필요하면(예: 검색) **정당화 주석**(`// zalkera-allow-dynamic:
|
|
447
|
-
<이유>`)이 필요하다 — 서버 동적 렌더는
|
|
659
|
+
<이유>`)이 필요하다 — 서버 동적 렌더는 **상시 런타임 원가**라 SEO 라우트에서는 기본이 아니다.
|
|
448
660
|
|
|
449
661
|
## 5.1 산출물 규범 — 발견되는 사이트 (필수)
|
|
450
662
|
|
|
@@ -454,8 +666,8 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
454
666
|
- ✅ **SEO 페이지에 JSON-LD(schema.org) 필수.** 상품 상세 = `Product` + variant 마다 `Offer`(price·
|
|
455
667
|
priceCurrency·availability) + 후기 있으면 `AggregateRating`. 홈 = `Organization`(오프라인 점포면
|
|
456
668
|
`LocalBusiness`, 뷰티샵이면 `BeautySalon` 으로 좁힌다). 목록·상세엔 `BreadcrumbList`.
|
|
457
|
-
|
|
458
|
-
|
|
669
|
+
**§4.10 의 부품**(안전 직렬화 + `productJsonLd`/`organizationJsonLd`/`breadcrumbJsonLd`)을
|
|
670
|
+
**그대로 복사해 쓴다 — 재발명 금지**.
|
|
459
671
|
- ✅ **목록 라우트도 그래프를 낸다 — `ItemList`.** 상품 목록(`/products`)·글 목록(`/blog`)은 **화면에 그리는
|
|
460
672
|
바로 그 순서·그 항목**으로 `ItemList` 를 낸다(`itemListJsonLd`). 왜 목록에도 그래프가 필요한가: 상세 N건만
|
|
461
673
|
있으면 "이 가게가 무엇을 파는가"를 기계가 한 번에 못 받고 상세를 하나씩 발견해야 한다 — 목록은 그 N건을
|
|
@@ -465,28 +677,33 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
465
677
|
내지 않는다**(빈 목록을 그래프로 주장하지 않는다 — "페이지에 없는 것을 쓰지 마라"의 목록판). 목록에도
|
|
466
678
|
`BreadcrumbList` 를 함께 낸다. 그리고 목록은 **ISR 로 유지한다** — 정렬·필터를 `searchParams` 로 받는 순간
|
|
467
679
|
라우트가 동적 렌더로 강등돼 크롤러 방문마다 서버 렌더가 돈다. 더 넓히려면 `/products/page/[n]` 같은
|
|
468
|
-
**정적 세그먼트**로 늘려라.
|
|
680
|
+
**정적 세그먼트**로 늘려라.
|
|
681
|
+
- ✅ **카테고리 목록 = `CollectionPage`(`mainEntity` 로 `ItemList`).** 카테고리 라우트(`/c/{slug}`)는 그 묶음이
|
|
682
|
+
**무엇의 모음인지**를 말하는 `CollectionPage` 를 내고, 그 안에 실제 항목을 `mainEntity` 로 품은 `ItemList` 를
|
|
683
|
+
넣는다(`collectionPageJsonLd`). 상품 목록의 `ItemList` 와 나누는 이유는 층이 다르기 때문이다 — `/products` 는
|
|
684
|
+
"이 가게가 파는 것 전부"이고 카테고리는 "그중 이 갈래"라, 후자에는 **묶음 자체의 이름·설명**이 붙는다.
|
|
685
|
+
목록과 같은 규율을 그대로 따른다: 각 `ListItem` 은 `url` + **이름만** 들고 가는 요약형(가격의 정본은 상세의
|
|
686
|
+
`Offer` 다), **항목이 0건이면 그래프를 내지 않는다**, `BreadcrumbList` 를 함께 내고 **ISR 로 유지**한다.
|
|
687
|
+
카테고리가 하나도 없는 사이트는 이 라우트를 내비·sitemap 에 올리지 않는다 — 열면 빈 선반이 된다.
|
|
469
688
|
- ✅ **예약(시술) 사이트의 목록 보장은 `SERVICE_MENU` 섹션이 낸다.** 섹션 어휘 `SERVICE_MENU` 는 계약상
|
|
470
689
|
`jsonLd: "ItemList"` 이고(`SECTION_CONTRACT` · `contractRev` 2 이상), config 의 상품 참조 배열
|
|
471
690
|
(DB 방언 `productIds` · 소스 방언 `products` — §9.2) **순서 그대로**
|
|
472
691
|
목록 그래프를 낸다 — 원장이 정한 노출 순서를 기계에도 같은 순서로 준다. 시술 목록의 정위치는 **홈의 이
|
|
473
692
|
섹션**이지 별도 라우트가 아니다: 뷰티 사이트에 목록 라우트를 강제하는 것은 디자인 자유를 깎으면서 얻는
|
|
474
693
|
것이 없어서, 예약 유형의 목록 보장은 이 산출로 충족한다(`FAQ_LIST`↔`FAQPage` 와 같은 선례 — 목록의 정본이
|
|
475
|
-
그 섹션의 배열이라 다른 데서 다시 만들면 두 벌이 되고 갈라진다).
|
|
476
|
-
`src/components/sections/ServiceMenuSection.tsx`.
|
|
694
|
+
그 섹션의 배열이라 다른 데서 다시 만들면 두 벌이 되고 갈라진다).
|
|
477
695
|
- ✅ **CMS 고정 페이지(`/[slug]`)를 비워 두지 마라 — `WebPage` + `BreadcrumbList`.** 홈은 `Organization`,
|
|
478
696
|
상품은 `Product`, 글은 `BlogPosting` 을 내는데 콘솔·시드가 만든 서브페이지(회사소개·이용안내 등)만 그래프가
|
|
479
697
|
비기 쉽다. 순수 마케팅 사이트는 **그 서브페이지가 콘텐츠의 전부**라, 비어 있으면 답변 엔진이 인용할 노드가
|
|
480
698
|
홈 하나뿐이 된다. `name` 은 페이지가 그리는 `<h1>` 과 같은 값으로, `description` 은 SEO 오버라이드가
|
|
481
699
|
**실제로 있을 때만** 붙인다(`datePublished`·`author`·`image` 는 그 페이지에 그런 것이 없으므로 내지 않는다).
|
|
482
|
-
서브페이지의 `BreadcrumbList` 는 홈 아래 1뎁스(`홈 > 회사소개`)다.
|
|
483
|
-
`webPageJsonLd`.
|
|
700
|
+
서브페이지의 `BreadcrumbList` 는 홈 아래 1뎁스(`홈 > 회사소개`)다. 그래프는 `webPageJsonLd`(§4.10)로 낸다.
|
|
484
701
|
- ✅ **`sitemap.ts`·`robots.ts` 필수.** sitemap 엔 **실제로 존재하는 공개 라우트만** — 홈 · **목록 라우트
|
|
485
702
|
(`/products`·`/blog`)** · 상품 상세 · 글 상세 · 콘텐츠 페이지. 목록 라우트를 빼면 카탈로그의 허브가 크롤러에게
|
|
486
703
|
안 알려져 상세 N건을 개별 발견에만 맡기게 된다. 반대로 **비어 있는 목록은 싣지 않는다** — 상품 0건에
|
|
487
704
|
`/products` 를, 글 0건에 `/blog` 를 실으면 크롤러에게 빈 페이지를 색인시키는 것이라 위 `ItemList` 규칙과
|
|
488
|
-
같은 판단이다(둘 다 "없는 것을 있다고 말하지 않는다").
|
|
489
|
-
|
|
705
|
+
같은 판단이다(둘 다 "없는 것을 있다고 말하지 않는다"). **목록 API 를 훑어 0건이면 빼는 조건부 등재**를
|
|
706
|
+
`sitemap.ts` 안에서 한다. robots 는 세션·쓰기 경로(`/api/`·`/cart`·`/checkout`·`/mypage`·`/orders`·`/login`·
|
|
490
707
|
`/auth`)만 막고 공개 카탈로그는 전부 연다. **AI 크롤러(GPTBot·ClaudeBot 등)를 막지 마라** — 발견 경로를
|
|
491
708
|
우리 손으로 닫는 것이다.
|
|
492
709
|
- ✅ **절대 URL.** JSON-LD·sitemap·robots 는 상대경로 불가. 단일 사이트는 env(`ZALKERA_SITE_URL`),
|
|
@@ -496,7 +713,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
496
713
|
- ❌ **페이지에 없는 것을 JSON-LD 에 쓰지 마라.** 구조화 데이터는 **보이는 내용만** 서술한다. 후기 0건인데
|
|
497
714
|
`aggregateRating`, 렌더하지도 않는 `image`, 지어낸 브랜드·재고는 전부 구조화 데이터 정책 위반(리치결과 박탈).
|
|
498
715
|
값이 없으면 **그 필드를 통째로 뺀다**(널·0 을 넣지 않는다).
|
|
499
|
-
- ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.**
|
|
716
|
+
- ✅ **상품 이미지는 `/media/{id}` 안정 URL 로.** §4.9 의 프록시 라우트가 `X-Tenant` 를 붙여
|
|
500
717
|
백엔드를 부르고 302 Location 만 넘긴다(바이트는 스토리지→브라우저 직행 — Next 런타임에 태우지 마라).
|
|
501
718
|
`<img src={`/media/${product.coverAssetId}`} loading="lazy">` + JSON-LD `image` 둘 다 이 URL 을 쓴다.
|
|
502
719
|
`next/image` 는 최적화 프록시가 바이트를 런타임에 태우므로 금지(쓰려면 `unoptimized`).
|
|
@@ -512,9 +729,7 @@ export default async function StaticPage({ params }: { params: Promise<{ slug: s
|
|
|
512
729
|
```bash
|
|
513
730
|
npx zalkera-aeo-check https://개시된사이트 --category BOOKING # 0=통과 · 1=미충족 · 2=실행 불가
|
|
514
731
|
npx zalkera-aeo-check https://개시된사이트 --site-wide-only # 보장 주장이 없는 사이트(사이트 축만)
|
|
515
|
-
|
|
516
|
-
npm run check:aeo -- https://개시된사이트 --category BOOKING
|
|
517
|
-
npm run check:aeo -- --print-guarantees # 잣대 해석만 확인(크롤 없음)
|
|
732
|
+
npx zalkera-aeo-check --print-guarantees # 잣대 해석만 확인(크롤 없음)
|
|
518
733
|
```
|
|
519
734
|
|
|
520
735
|
- ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
|
|
@@ -569,15 +784,83 @@ catch (e) {
|
|
|
569
784
|
|
|
570
785
|
## 8. 스타일 규약 — Tailwind v4 + 테마 토큰 (필수)
|
|
571
786
|
|
|
572
|
-
|
|
573
|
-
생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다.
|
|
787
|
+
잘커라 사이트는 **Tailwind v4** 와 **테마 토큰**으로 스타일한다. 화면을 그릴 때 아래를 지킨다 — 어기면
|
|
788
|
+
생성물이 초라해지거나(생짜 HTML) 테넌트의 "말로 색 바꾸기"가 깨진다.
|
|
789
|
+
|
|
790
|
+
> 이 절은 **규약이지 게이트가 아니다.** 어떤 스택으로 짜든 사이트는 개시된다 — 판정 잣대는 §5.1 의
|
|
791
|
+
> 산출물 검사(`zalkera-aeo-check`)뿐이고 소스를 보지 않는다. 다만 테마 토큰을 안 쓰면 테넌트가
|
|
792
|
+
> 콘솔에서 색을 바꿔도 화면이 안 따라온다(그건 검사기가 아니라 **기능이 빠지는** 것이다).
|
|
574
793
|
|
|
575
794
|
**스택 — 이것만 쓴다**
|
|
576
795
|
- ✅ **Tailwind v4 유틸리티 클래스로만** 스타일한다. CSS 파일은 `src/app/globals.css` **하나뿐**이다 —
|
|
577
|
-
새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지
|
|
578
|
-
- ❌ 인라인 `style={{}}
|
|
796
|
+
새 `.css` 파일·CSS Modules·CSS-in-JS 를 추가하지 마라.
|
|
797
|
+
- ❌ 인라인 `style={{}}`, 웹폰트·외부 스타일 CDN 추가, `tailwind.config.*` 생성(v4 는 config 없이 돈다).
|
|
579
798
|
인라인 style 은 **CSS 변수 주입**(`style={{"--x":v}}`) 한 용례에만 허용된다(루트 layout 의 테마 주입이 그것).
|
|
580
799
|
|
|
800
|
+
**토큰 정의 — `globals.css` 의 `@theme` 블록 (없으면 아래 유틸리티가 존재하지 않는다)**
|
|
801
|
+
|
|
802
|
+
Tailwind v4 는 config 없이 돌고 토큰을 **CSS 에서** 선언한다. 이 블록이 없으면 `bg-primary`·`text-muted` 같은
|
|
803
|
+
클래스가 **그냥 안 먹는다**(에러도 안 난다). §4.9 의 미디어 라우트와 같은 부류의 필수 부품이라 전문을 싣는다.
|
|
804
|
+
|
|
805
|
+
```css
|
|
806
|
+
@import "tailwindcss";
|
|
807
|
+
|
|
808
|
+
@theme {
|
|
809
|
+
/* 테넌트 오버라이드 지점 — themeColors 가 <html> inline style 로 이 값들을 덮는다 */
|
|
810
|
+
--color-primary: oklch(20.8% 0.042 265.755); /* slate-900 — 테넌트 색이 얹히기 전 기본 */
|
|
811
|
+
--color-primary-foreground: #ffffff; /* primary 위 글자색(서버가 명도로 산출) */
|
|
812
|
+
--color-secondary: oklch(55.4% 0.046 257.417);
|
|
813
|
+
--color-background: #ffffff;
|
|
814
|
+
--color-foreground: oklch(20.8% 0.042 265.755);
|
|
815
|
+
|
|
816
|
+
/* slate 중립 스케일에서 파생 — 테넌트 오버라이드 대상이 아니다(콘솔 스키마에 키가 없다) */
|
|
817
|
+
--color-muted: oklch(55.4% 0.046 257.417);
|
|
818
|
+
--color-border: oklch(92.9% 0.013 255.508);
|
|
819
|
+
--color-surface: oklch(98.4% 0.003 247.858);
|
|
820
|
+
--color-danger: oklch(57.7% 0.245 27.325);
|
|
821
|
+
|
|
822
|
+
/* 웹폰트 다운로드 없음 — 시스템 스택. font knob 이 --font-sans 를 덮는다 */
|
|
823
|
+
--font-sans: ui-sans-serif, system-ui, -apple-system, "Apple SD Gothic Neo",
|
|
824
|
+
"Malgun Gothic", "Noto Sans KR", sans-serif;
|
|
825
|
+
|
|
826
|
+
/* radius knob — 무단위 배수가 스케일 전체를 곱한다. sharp=0 · soft=1(기본) · round=2.
|
|
827
|
+
var() 폴백 1 이라 knob 미주입 시에도 Tailwind 기본 스케일과 정확히 일치한다. */
|
|
828
|
+
--radius-knob: 1;
|
|
829
|
+
--radius: calc(0.25rem * var(--radius-knob, 1));
|
|
830
|
+
--radius-sm: calc(0.25rem * var(--radius-knob, 1));
|
|
831
|
+
--radius-md: calc(0.375rem * var(--radius-knob, 1));
|
|
832
|
+
--radius-lg: calc(0.5rem * var(--radius-knob, 1));
|
|
833
|
+
--radius-xl: calc(0.75rem * var(--radius-knob, 1));
|
|
834
|
+
--radius-2xl: calc(1rem * var(--radius-knob, 1));
|
|
835
|
+
|
|
836
|
+
/* density knob — 이 베이스가 전 여백을 스케일한다(cozy=0.25rem 기본 · compact=0.22rem) */
|
|
837
|
+
--spacing: 0.25rem;
|
|
838
|
+
}
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
루트 `layout` 이 이 값들을 덮어 쓰는 것이 "말로 색 바꾸기"의 전부다:
|
|
842
|
+
|
|
843
|
+
```tsx
|
|
844
|
+
const {cssVars} = parseThemeColors(config.themeColors); // §3 계약 헬퍼 — 화이트리스트 파서
|
|
845
|
+
return <html lang="ko" style={cssVars}>{/* … */}</html>;
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
`@theme` 만으로는 부족하다. Tailwind preflight 가 제목·폼 요소를 리셋하므로, **base 레이어가 없으면
|
|
849
|
+
아래 레시피의 "`<h1>` 그대로"·"폼은 맨 요소 그대로"가 본문 크기 제목과 생짜 input 을 낳는다.**
|
|
850
|
+
같은 파일에 이어 둔다:
|
|
851
|
+
|
|
852
|
+
```css
|
|
853
|
+
@layer base {
|
|
854
|
+
h1 { @apply text-2xl font-semibold tracking-tight; }
|
|
855
|
+
h2 { @apply text-lg font-semibold tracking-tight; }
|
|
856
|
+
a { @apply underline-offset-4 hover:underline; } /* 색은 상속 — 액센트 남발 금지 */
|
|
857
|
+
input, select, textarea {
|
|
858
|
+
@apply w-full rounded-lg border border-border bg-background px-3 py-2 text-sm
|
|
859
|
+
placeholder:text-muted focus:outline-2 focus:outline-primary;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
```
|
|
863
|
+
|
|
581
864
|
**토큰 — 색은 토큰으로만**
|
|
582
865
|
| 유틸리티 | 뜻 |
|
|
583
866
|
|---|---|
|
|
@@ -588,7 +871,7 @@ catch (e) {
|
|
|
588
871
|
| `border-border` | 경계선 | `bg-surface` | 카드·필드 배경 | `text-danger` | 오류 |
|
|
589
872
|
|
|
590
873
|
- ✅ 테넌트 브랜드색은 **`primary` 토큰으로만** 표현한다. ❌ hex 하드코딩(`bg-[#e91e63]`)은 콘솔의
|
|
591
|
-
"말로 색 바꾸기"를
|
|
874
|
+
"말로 색 바꾸기"를 죽인다.
|
|
592
875
|
- ✅ 중립 명도 단계가 토큰 표에 없으면 **slate 스케일만**(`text-slate-400` 등). ❌ gray·zinc·stone 혼용,
|
|
593
876
|
유채색 팔레트(rose·emerald 등) 직접 사용 금지 — 새 색이 필요하면 **지어내지 말고 보고**한다.
|
|
594
877
|
- 간격·타이포·라운드·섀도는 **Tailwind 기본 스케일**(`p-4`·`text-2xl`·`rounded-lg`·`shadow-sm`)을 쓴다.
|
|
@@ -596,7 +879,7 @@ catch (e) {
|
|
|
596
879
|
|
|
597
880
|
**레시피**
|
|
598
881
|
- 페이지 `<main className="py-8">` · 섹션 `<section className="mb-12">` · 제목은 base 가 크기를 주므로 `<h1>` 그대로.
|
|
599
|
-
-
|
|
882
|
+
- 버튼·카드는 **프리미티브 한 벌**을 만들어 재사용한다(화면마다 새로 만들지 마라).
|
|
600
883
|
- 폼 필드(`<input>`·`<select>`·`<textarea>`)는 base 레이어가 스타일하므로 **맨 요소 그대로** 쓴다.
|
|
601
884
|
|
|
602
885
|
**절제 — 화려하게 만들지 마라**
|
|
@@ -604,7 +887,8 @@ catch (e) {
|
|
|
604
887
|
|
|
605
888
|
**전역 토큰 knob — 말로 폰트·모서리·밀도 바꾸기 (계약버전 2)**
|
|
606
889
|
색 외에 **폰트·모서리 둥글기·여백 밀도**도 테넌트가 콘솔에서 "말로" 바꾼다(`site.theme.update`). 값은 **enum**
|
|
607
|
-
뿐이며 `
|
|
890
|
+
뿐이며 **이 패키지의 `parseThemeColors`**(§3 계약 헬퍼)가 CSS 변수로 변환해 색과 같은 경로
|
|
891
|
+
(`<html>` inline 주입)로 전 화면에 반영된다. 매핑 테이블을 직접 만들지 마라 — 그 함수가 정본이다.
|
|
608
892
|
|
|
609
893
|
| knob | enum 값 | 효과 |
|
|
610
894
|
|---|---|---|
|
|
@@ -617,22 +901,22 @@ catch (e) {
|
|
|
617
901
|
- 새 knob 값(예: 다른 폰트)이 필요하면 **지어내지 말고 보고**한다 — enum 확장은 계약버전 증가를 동반한다.
|
|
618
902
|
|
|
619
903
|
**테마 동작 — 배선을 건드리지 마라**
|
|
620
|
-
- 테넌트 색은 `getSiteConfig().themeColors` → **루트 `layout` 이 `<html>` 의 CSS
|
|
621
|
-
|
|
904
|
+
- 테넌트 색은 `getSiteConfig().themeColors` → `parseThemeColors` → **루트 `layout` 이 `<html>` 의 CSS
|
|
905
|
+
변수로 주입**한다. 이 배선을 제거·우회하지 마라. **색 변경에 코드 수정이 필요하면 설계 위반**이다 —
|
|
622
906
|
콘솔에서 색을 바꾸면 재코딩 없이 반영되는 것이 정상이다.
|
|
623
907
|
|
|
624
|
-
**UI 프리미티브 관용구 —
|
|
908
|
+
**UI 프리미티브 관용구 — 한 벌만 만들어 재사용한다**
|
|
625
909
|
- `cn(...)` = `twMerge(clsx(...))`. 조건부 클래스와 **덮어쓰기**(`cn("px-4", props.className)` 에서 `px-6` 이 이김)를
|
|
626
910
|
둘 다 처리한다. 문자열 이어붙이기로 대체하지 마라 — 덮어쓰기가 조용히 안 먹는다.
|
|
627
|
-
- 변형(variant)은 **cva** 로
|
|
628
|
-
-
|
|
911
|
+
- 변형(variant)은 **cva** 로 선언한다. 조건문으로 클래스 문자열을 조립하지 마라.
|
|
912
|
+
- 버튼·카드·아이콘은 **프리미티브 한 벌**로 두고 화면마다 새로 만들지 마라. `<Link>`·`<a>` 에는
|
|
913
|
+
같은 변형을 **클래스 문자열로 내주는 헬퍼**를 함께 둔다(버튼 컴포넌트를 앵커로 감싸지 않는다).
|
|
629
914
|
- 새 프리미티브가 정말 필요하면 shadcn/ui 에서 **발췌**해 온다(CLI 상시 설치 아님). 발췌물은 아래 재작성 표를
|
|
630
915
|
적용하고 `asChild`/`Slot` 을 제거해 **Radix 의존 0** 을 유지한 뒤 `src/components/ui/` 에 커밋한다.
|
|
631
916
|
|
|
632
917
|
**shadcn 발췌 재작성 표 — 남의 토큰 어휘는 반입 금지**
|
|
633
918
|
shadcn 소스는 자기 변수층(`--card`·`--muted-foreground` …)을 전제한다. 우리는 그 층을 들이지 않는다(토큰 레이어가
|
|
634
|
-
둘이 되면 테넌트 색 주입의 우선순위가 사람 머리에 얹힌다). 발췌 시 **좌변을 우변으로 기계적으로
|
|
635
|
-
CI validator **S6** 가 좌변 잔존을 error 로 막는다.
|
|
919
|
+
둘이 되면 테넌트 색 주입의 우선순위가 사람 머리에 얹힌다). 발췌 시 **좌변을 우변으로 기계적으로 바꾼다** — 아래 표가 그 규범이다.
|
|
636
920
|
|
|
637
921
|
| shadcn | 우리 |
|
|
638
922
|
|---|---|
|
|
@@ -665,7 +949,7 @@ JSON)뿐이고, 백엔드는 config 를 **파싱하지 않는다** — 스키마
|
|
|
665
949
|
|
|
666
950
|
| 선언 | 의미 |
|
|
667
951
|
|---|---|
|
|
668
|
-
| `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**.
|
|
952
|
+
| `"source"` | 사이트의 얼굴(페이지·섹션·문구·섹션 이미지·내비)의 정본이 **레포 파일**. **새로 만드는 사이트는 이 형상을 쓴다**(v1 팩 태생 사이트가 남아 있는 동안 그쪽은 `sections-db` 다) |
|
|
669
953
|
| `"sections-db"` | 전환기 표기 — 섹션이 백엔드에 있고 `getPage(slug)` 가 준다(구 프리셋 태생 사이트) |
|
|
670
954
|
| 미선언 | 안전 기본 — **선언이 없으면 이 계약을 안 쓰는 레포로 본다**(계약 검사도 걸지 않는다) |
|
|
671
955
|
|
|
@@ -782,8 +1066,17 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
|
|
|
782
1066
|
SSR 마크업에 실린다**(AI 답변엔진이 읽는다). 아코디언 라이브러리를 쓰지 마라. `FAQPage` JSON-LD 를 함께 낸다.
|
|
783
1067
|
- `TESTIMONIALS` 에 **`Review`·`AggregateRating` 을 내지 마라.** 자사 사이트의 자사 후기에 별점을 붙이는 것은
|
|
784
1068
|
self-serving reviews 정책 위반이라 제재 대상이다. 이건 빠뜨린 게 아니라 결정이다.
|
|
785
|
-
- `LEAD_CTA`
|
|
786
|
-
전환 부품을 두 벌 만들지
|
|
1069
|
+
- `LEAD_CTA` 의 리드 폼은 **클라이언트 아일랜드**(`"use client"`)로 만들고 `/api/lead` BFF 로 POST 한다
|
|
1070
|
+
(`clientIp` 는 서버가 XFF 에서 붙인다). 전환 부품을 두 벌 만들지 마라 — 하나로 재사용한다. 요건 넷:
|
|
1071
|
+
· **연락처 필수 · 이메일 선택**(문의 폼과 반대다). 본문은 `{name, phone, email?, message?, interest,
|
|
1072
|
+
isQuick, consentMarketing, tracking}`.
|
|
1073
|
+
· **UTM·클릭ID 8키**를 `tracking` 으로 동봉한다 — `utm_source`·`utm_medium`·`utm_campaign`·
|
|
1074
|
+
`utm_adgroup`·`utm_content`·`fbclid`·`gclid`·`nclid`. 하나라도 있으면 채운다.
|
|
1075
|
+
· **캡처는 mount 후 `window.location.search`** 로 한다. 랜딩이 force-static ISR 일 수 있어 RSC 에서
|
|
1076
|
+
`searchParams` 를 읽으면 정적 셸이 깨진다 — `useSearchParams()` 도 같은 이유로 금지.
|
|
1077
|
+
· **`TOO_MANY_REQUESTS` 안내를 반드시 넣는다**(공개 폼이라 레이트리밋이 흔하다). 400 은 `errors[]` 를
|
|
1078
|
+
필드별로 표시하고, 성공 시 입력만 비우고 **`tracking` 은 유지**한다(같은 방문의 재제출도 같은 유입 귀속).
|
|
1079
|
+
이 섹션은 **`id="lead"` 앵커**를 갖는다 — 원페이지 랜딩에서 히어로 CTA 를
|
|
787
1080
|
`"ctaHref": "#lead"` 로 여기에 걸 수 있다. 앵커를 지우면 그 버튼이 조용히 아무 데도 안 간다.
|
|
788
1081
|
- `SERVICE_MENU` 는 상품 참조가 **필수**다(rev 3). 참조가 0 이면 렌더러가 그 섹션을 안 그려서 **개시 직후
|
|
789
1082
|
조용히 사라진다** — 시술 목록의 AEO 보장(`ItemList`·§5.1)이 그 섹션에 걸려 있으므로 보장까지 함께 죽는다.
|
|
@@ -792,8 +1085,9 @@ page.sections // [{ type: "HERO", sortOrder: 0, config: "{\"title\":\"…\"}" },
|
|
|
792
1085
|
- `LOGO_WALL` 에 실존 기업 로고를 넣지 마라 — 사용 허락이 있는 것만.
|
|
793
1086
|
|
|
794
1087
|
**아이콘 — 큐레이션 맵의 키 문자열만**
|
|
795
|
-
`FEATURE_GRID` 의 `icon` 은 아래 32개 중 하나다.
|
|
796
|
-
생략**한다(죽지 않는다·기본
|
|
1088
|
+
`FEATURE_GRID` 의 `icon` 은 아래 32개 중 하나다. 렌더러는 **이 키 → 글리프 맵 lookup** 으로 짜고,
|
|
1089
|
+
**미지 이름은 아이콘 영역을 생략**한다(죽지 않는다·기본 글리프로 때우지도 않는다 — 틀린 아이콘보다
|
|
1090
|
+
없는 아이콘이 낫다).
|
|
797
1091
|
|
|
798
1092
|
```
|
|
799
1093
|
shield-check rocket line-chart trending-up users user-check clock calendar-check
|