@roottale/cms-mcp 0.62.0 → 0.64.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/CHANGELOG.md +30 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +114 -4
- package/docs/blog.md +11 -1
- package/docs/inquiries.md +185 -12
- package/docs/overview.md +4 -3
- package/docs/popups-and-banners.md +40 -1
- package/docs/theme-and-settings.md +52 -14
- package/examples/nextjs/app/blog/[slug]/page.tsx +3 -0
- package/examples/nextjs/app/contact/page.tsx +15 -0
- package/examples/nextjs/app/preview/post/[id]/page.tsx +3 -0
- package/examples/nextjs/components/site-exposures.tsx +3 -0
- package/examples/nextjs/components/site-inquiry-form.tsx +122 -0
- package/examples/nextjs/lib/actions/submit-contact.ts +5 -3
- package/examples/nextjs/lib/actions/submit-site-form.ts +25 -0
- package/examples/nextjs/lib/site-inquiry-forms.ts +22 -0
- package/examples/nextjs/media-upload.md +28 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.64.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5ccf503: 한 사이트의 여러 문의 폼을 발행 버전별로 연결할 수 있도록 공통 폼 정의·검증 계약과 `fetchInquiryForm`·`submitFormInquiry` 서버 SDK를 추가합니다. 구조화 답변·동의·중복 재시도·접수증과 같은 사이트의 견적/AS 폼 연동 예제를 제공합니다. 기존 문의 SDK는 유지합니다.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- faf7bb5: 관리자 편집 내용을 고객 사이트의 실제 팝업·배너 슬롯에서 미리 볼 수 있도록 출처·창·세션을 검증하는 iframe 연결을 추가합니다. 실제 CSS·배치·닫기 설정을 재사용하며 노출 판정 조회와 숨김 기록 없이 편집합니다. 미리보기 연결과 배포 조건을 문서·Next.js 예제에 설명합니다.
|
|
12
|
+
- 5e124de: 미디어 보존 활성화 시 원본 암호화 보관과 검증 이후 업로드를 완료하고, 임시 업로드 경로와 확정 원본을 분리합니다. 완료 재시도는 같은 미디어를 반환하며 삭제 원장은 지연된 완료 요청의 복원을 막습니다. 외부 업로드 연동 문서와 재시도 예제를 갱신합니다.
|
|
13
|
+
|
|
14
|
+
## 0.63.0
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- a858d8e: 목차 배치를 사이트 코드의 `RootTaleBlogPost.tocPosition`으로 관리할 수 있습니다.
|
|
19
|
+
명시한 값은 CMS 설정보다 우선하며, 생략한 기존 연동은 CMS 배치를 유지합니다.
|
|
20
|
+
현재 배치를 prop으로 명시하면 실제 화면을 바꾸지 않고 관리 주체를 코드로 옮깁니다.
|
|
21
|
+
|
|
22
|
+
공통 블록 디자인은 `RootTaleBlogPost.footerPatternPresentation`과
|
|
23
|
+
`RootTalePostPattern.presentation`으로 전체 대체할 수 있습니다. `null`은 원격
|
|
24
|
+
디자인 주입을 생략하고 사이트 CSS에 맡기며, 생략한 기존 연동은 원격값을 유지합니다.
|
|
25
|
+
고객 콘텐츠와 제작 디자인을 분리하는 문서·발행/미리보기 예제를 함께 갱신합니다.
|
|
26
|
+
|
|
27
|
+
공개 `RootTaleCssVars` 타입의 비공개 UI 패키지 참조도 제거해 독립 소비자의
|
|
28
|
+
strict 타입 검사를 지원합니다.
|
|
29
|
+
|
|
30
|
+
섹션 구성·디자인 변경 검사 함수를 공개 CMS core에서 공유해 편집기와 저장 단계가
|
|
31
|
+
같은 고객 편집 경계를 적용합니다.
|
|
32
|
+
|
|
3
33
|
## 0.62.0
|
|
4
34
|
|
|
5
35
|
### Minor Changes
|
package/dist/index.js
CHANGED
|
@@ -1735,7 +1735,7 @@ function registerTools(server) {
|
|
|
1735
1735
|
}
|
|
1736
1736
|
|
|
1737
1737
|
// src/server.ts
|
|
1738
|
-
var VERSION = true ? "0.
|
|
1738
|
+
var VERSION = true ? "0.64.0" : "dev";
|
|
1739
1739
|
var SERVER_INSTRUCTIONS = `
|
|
1740
1740
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
|
|
1741
1741
|
\uAE00\xB7\uC378\uB124\uC77C\xB7\uBCF8\uBB38 \uC774\uBBF8\uC9C0\uB97C \uC790\uB3D9\uD654\uD558\uAE30 \uC704\uD55C \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7API tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
|
package/docs/api-reference.md
CHANGED
|
@@ -19,7 +19,8 @@ MCP tool 또는 공개 CLI를 사용합니다.
|
|
|
19
19
|
관리하려면 `full_management` 키를 권장합니다. `read` 키는 공개
|
|
20
20
|
콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수
|
|
21
21
|
있습니다. 다만 **완전한 읽기 전용은 아닙니다** — 상담 게시판 글 작성
|
|
22
|
-
(`POST /v1/cms/public/inquiries`)
|
|
22
|
+
(`POST /v1/cms/public/inquiries`)과 폼별 문의 접수
|
|
23
|
+
(`POST /v1/public/inquiry-forms/:formId/submissions`)가 `cms:read`로 허용됩니다. 기존 글·미디어·
|
|
23
24
|
설정을 고치거나 지우려면 `read_write` 이상이 필요합니다.
|
|
24
25
|
|
|
25
26
|
사업장 정보·상단 메뉴를 바꾸는 일(아래 "설정 쓰기 API")은 한 단계 위인
|
|
@@ -120,8 +121,7 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
120
121
|
|
|
121
122
|
## 미디어 업로드 API
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
업로드한 뒤 CMS에 등록합니다. 허용 형식은 JPEG, PNG, WebP, GIF, PDF이며
|
|
124
|
+
API가 발급한 5분 유효 업로드 URL로 파일을 전송한 뒤 CMS에 등록합니다. 허용 형식은 JPEG, PNG, WebP, GIF, PDF이며
|
|
125
125
|
최대 10MB입니다.
|
|
126
126
|
|
|
127
127
|
### 1. POST /v1/cms/media/uploads
|
|
@@ -161,6 +161,12 @@ curl -X PUT "$UPLOAD_URL" \
|
|
|
161
161
|
|
|
162
162
|
`r2_key`는 1단계 응답값을 바꾸지 말고 그대로 보냅니다. 서버가 R2 객체의
|
|
163
163
|
tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다.
|
|
164
|
+
파일 보존이 활성화된 환경에서는 원본의 별도 보존까지 확인한 뒤 등록합니다.
|
|
165
|
+
1단계의 `r2_key`는 업로드용 임시 경로이며, 완료 응답의 `r2_key`가 최종 원본
|
|
166
|
+
경로입니다. 공개 주소를 임시 경로로 직접 만들지 마세요.
|
|
167
|
+
완료 응답을 받지 못했거나 `503`을 받으면 같은 `r2_key`와 요청 본문으로 완료
|
|
168
|
+
요청을 재시도하세요. 새 등록은 `201`, 이미 완료된 업로드의 재시도는 같은
|
|
169
|
+
미디어 ID와 `200`을 반환합니다. 삭제된 업로드는 다시 완료할 수 없습니다.
|
|
164
170
|
응답의 `id`는 글의 `featured_media_id`에, `url`은 Tiptap image 노드의
|
|
165
171
|
`attrs.src`에 사용합니다.
|
|
166
172
|
|
|
@@ -174,6 +180,8 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
174
180
|
|
|
175
181
|
미디어 삭제 전 해당 URL을 쓰는 본문과 `featured_media_id` 연결을 먼저
|
|
176
182
|
교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다.
|
|
183
|
+
파일 보존이 활성화된 환경에서 원본 보존 또는 삭제 기록 저장에 실패하면
|
|
184
|
+
`503`을 반환하며 삭제를 완료하지 않습니다.
|
|
177
185
|
|
|
178
186
|
## 설정 쓰기 API
|
|
179
187
|
|
|
@@ -631,9 +639,111 @@ ROOT-ANALYTICS 사이트 ID와 외부 태그 설정.
|
|
|
631
639
|
웹훅 서명 검증용 site-scoped JWKS 공개키. **site-scoped 키 필수** — 테넌트
|
|
632
640
|
전체 키로 호출하면 `400 site_scope_required`.
|
|
633
641
|
|
|
642
|
+
## GET /v1/public/inquiry-forms/:formId
|
|
643
|
+
|
|
644
|
+
사이트에 등록한 활성 문의 폼의 현재 발행 정의를 읽습니다. `cms:read` 고객 키를
|
|
645
|
+
사이트 서버에서 사용하며, **고객사 전체 키에는 `site_id` 쿼리가 필수**입니다.
|
|
646
|
+
사이트 한정 키는 해당 사이트에 고정됩니다. 다른 고객사·사이트의 폼은 조회하거나
|
|
647
|
+
접수할 수 없습니다. 비활성·미등록·범위 밖 폼은 `404 not_found`입니다.
|
|
648
|
+
|
|
649
|
+
```json
|
|
650
|
+
{
|
|
651
|
+
"form_id": "01993841-7930-7000-8000-000000000001",
|
|
652
|
+
"site_id": "01993841-7930-7000-8000-000000000002",
|
|
653
|
+
"form_key": "quote",
|
|
654
|
+
"version": 2,
|
|
655
|
+
"definition": {
|
|
656
|
+
"title": "견적 문의",
|
|
657
|
+
"kind": "sales",
|
|
658
|
+
"fields": [
|
|
659
|
+
{ "key": "phone", "label": "전화번호", "type": "tel", "role": "phone", "required": true },
|
|
660
|
+
{ "key": "message", "label": "문의 내용", "type": "textarea", "role": "message", "required": true }
|
|
661
|
+
],
|
|
662
|
+
"requireOneOf": [],
|
|
663
|
+
"privacyConsentText": "견적 상담을 위한 연락처 수집에 동의합니다.",
|
|
664
|
+
"successMessage": "문의가 접수되었습니다."
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
한 사이트에 견적·AS 등 폼 ID를 여러 개 등록할 수 있습니다. 각 폼의 버전과
|
|
670
|
+
입력 항목은 독립적입니다. 정의 계약은 공개 패키지 `@roottale/inquiry-forms`,
|
|
671
|
+
SDK는 `fetchInquiryForm`입니다. 응답은 `no-store`로 다룹니다.
|
|
672
|
+
|
|
673
|
+
## POST /v1/public/inquiry-forms/:formId/submissions
|
|
674
|
+
|
|
675
|
+
폼별 CRM 접수. `application/json`, `cms:read` 고객 키를 사용합니다.
|
|
676
|
+
사이트 범위는 위 GET과 같고 고객사 전체 키에는 body의 `site_id`가 필수입니다.
|
|
677
|
+
|
|
678
|
+
```json
|
|
679
|
+
{
|
|
680
|
+
"site_id": "01993841-7930-7000-8000-000000000002",
|
|
681
|
+
"version": 2,
|
|
682
|
+
"answers": { "phone": "010-1234-5678", "message": "옥상 방수 견적을 요청합니다." },
|
|
683
|
+
"privacy_consent": true,
|
|
684
|
+
"idempotency_key": "01993841-7930-7000-8000-000000000005",
|
|
685
|
+
"placement": "contact/quote"
|
|
686
|
+
}
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
| 필드 | 필수 | 설명 |
|
|
690
|
+
|---|---|---|
|
|
691
|
+
| `site_id` | 고객사 전체 키 사용 시 | 폼이 속한 사이트 ID |
|
|
692
|
+
| `version` | ✅ | 방문자가 보고 동의한 발행 버전, 양의 정수 |
|
|
693
|
+
| `answers` | ✅ | 정의의 항목 키에 해당하는 답변. 문자열·문자열 배열·숫자·boolean 타입 보존 |
|
|
694
|
+
| `privacy_consent` | ✅ | 사용자 명시 동의 `true` |
|
|
695
|
+
| `idempotency_key` | ✅ | 같은 제출의 재시도에 재사용. 1~200자 `[A-Za-z0-9_-]` |
|
|
696
|
+
| `placement` | | 폼 배치 식별자 |
|
|
697
|
+
| `turnstile_token` | | 사이트의 보안 확인 토큰 |
|
|
698
|
+
| `attribution` | | 유입 객체. 기존 multipart의 `attr_` 접두 없이 `landing_path`, `utm_source` 등 사용 |
|
|
699
|
+
| `journey` | | 방문 여정 배열, 최대 30개. 기존 multipart의 JSON 문자열과 구분 |
|
|
700
|
+
|
|
701
|
+
이름·이메일·사업체명을 일괄 필수로 요구하지 않습니다. 서버가 폼의 항목·선택지·
|
|
702
|
+
필수 조건을 검증하고 전화 또는 이메일의 연락 수단을 확인합니다. 등록되지 않은
|
|
703
|
+
항목·선택지는 거부합니다. 폼의 업무 유형·항목 라벨은 서버 정의를 사용합니다.
|
|
704
|
+
|
|
705
|
+
새 저장은 `201`, 저장된 동일 키·동일 내용의 재시도는 `200`과 접수증을 반환합니다.
|
|
706
|
+
|
|
707
|
+
```json
|
|
708
|
+
{
|
|
709
|
+
"id": "01993841-7930-7000-8000-000000000003",
|
|
710
|
+
"inquiry_no": 17,
|
|
711
|
+
"received_at": "2026-09-12T08:30:00.000Z",
|
|
712
|
+
"replayed": false
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
`inquiry_no`는 정수입니다. HTTP `2xx`만으로 성공을 판단하지 말고 접수증을 확인하세요.
|
|
717
|
+
응답 유실·서버 오류에는 **동일한 키와 제출 내용 전체**로 다시 요청합니다. 서버는
|
|
718
|
+
문의·접수번호·알림 예약을 함께 저장하며 재시도 시 알림을 다시 만들지 않습니다.
|
|
719
|
+
이미 저장된 동일 제출은 폼의 변경·중지 후에도 같은 접수증을 반환합니다.
|
|
720
|
+
|
|
721
|
+
주요 오류는 `400 validation_error`, `409 form_version_conflict`,
|
|
722
|
+
`409 idempotency_key_conflict`, `404 not_found`, `429 rate_limited`입니다.
|
|
723
|
+
필드 검증 오류는 다음 구조를 사용합니다.
|
|
724
|
+
|
|
725
|
+
```json
|
|
726
|
+
{
|
|
727
|
+
"code": "validation_error",
|
|
728
|
+
"message": "Request validation failed",
|
|
729
|
+
"hint": null,
|
|
730
|
+
"retry_after": null,
|
|
731
|
+
"details": {
|
|
732
|
+
"field_errors": { "phone": ["연락처를 확인해주세요."] },
|
|
733
|
+
"form_errors": []
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
`form_version_conflict`는 새 정의와 동의 문구를 다시 확인한 뒤 제출해야 합니다.
|
|
739
|
+
`idempotency_key_conflict`에서는 자동으로 새 키를 만들어 중복 접수하지 마세요.
|
|
740
|
+
SDK `submitFormInquiry`가 접수증 검증과 구조화 오류 반환을 제공합니다.
|
|
741
|
+
[문의 폼 연동](./inquiries.md)에 같은 사이트의 두 폼을 연결하는 예제가 있습니다.
|
|
742
|
+
|
|
634
743
|
## POST /v1/public/inquiries
|
|
635
744
|
|
|
636
|
-
상담문의(리드) 접수. `multipart/form-data`.
|
|
745
|
+
기존 고정 필드 상담문의(리드) 접수. `multipart/form-data`.
|
|
746
|
+
새 사이트의 여러 폼은 위의 폼별 JSON 접수를 사용하세요.
|
|
637
747
|
|
|
638
748
|
| 필드 | 필수 | 비고 |
|
|
639
749
|
|---|---|---|
|
package/docs/blog.md
CHANGED
|
@@ -136,6 +136,9 @@ export default async function PostPage({
|
|
|
136
136
|
slugOrId={post.id}
|
|
137
137
|
showTableOfContents
|
|
138
138
|
tableOfContentsTitle="목차"
|
|
139
|
+
tocPosition="inline"
|
|
140
|
+
theme={null}
|
|
141
|
+
footerPatternPresentation={null}
|
|
139
142
|
relatedPostsCount={3}
|
|
140
143
|
breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
|
|
141
144
|
/>
|
|
@@ -166,7 +169,14 @@ export default async function PostPage({
|
|
|
166
169
|
또는 `collections` 로 라우팅됩니다. 현재 글에 카테고리가 없거나 후보가 없으면
|
|
167
170
|
렌더되지 않습니다.
|
|
168
171
|
|
|
169
|
-
목차
|
|
172
|
+
목차 위치는 사이트 코드의 `tocPosition="inline" | "sidebar"`로 정합니다. 명시한
|
|
173
|
+
값은 CMS 설정보다 우선하며, 생략한 기존 연동은 CMS의 `tocPosition`을 유지합니다.
|
|
174
|
+
관리 주체를 코드로 옮길 때는 현재 화면의 배치 값을 명시하세요. 발행 페이지와
|
|
175
|
+
미리보기에는 같은 값을 적용합니다. `theme={null}`은 원격 테마 조회·스타일 주입을
|
|
176
|
+
끄고, `footerPatternPresentation={null}`은 공통 블록 디자인을 사이트 CSS에 맡깁니다.
|
|
177
|
+
두 prop을 생략한 기존 연동은 원격 테마·공통 블록 디자인을 계속 사용합니다.
|
|
178
|
+
|
|
179
|
+
목차(ToC) 노출·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
|
|
170
180
|
(`theme-and-settings.md` 참고). 여러 글에 같은 CTA가 필요하면 공통 블록의
|
|
171
181
|
`post_footer` 자리를 쓰세요. 공통 블록으로 아직 옮기지 않은 기존 사이트는
|
|
172
182
|
`RootTaleBlogPost`가 레거시 `postCta`를 계속 렌더하며, 공통 블록이 배치되면
|
package/docs/inquiries.md
CHANGED
|
@@ -1,16 +1,187 @@
|
|
|
1
1
|
---
|
|
2
|
-
title:
|
|
3
|
-
description:
|
|
2
|
+
title: 문의 폼과 CRM 연동
|
|
3
|
+
description: 사이트별 여러 문의 폼을 발행 버전·구조화 답변·접수증으로 CRM에 연결
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# 문의 폼과 CRM 연동
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
한 고객사는 여러 사이트를, 한 사이트는 견적·AS·채용 등 여러 문의 폼을 가질 수
|
|
9
|
+
있습니다. 각 폼은 독립된 ID·업무 유형·발행 버전을 갖고, 제출 내용은 어드민
|
|
10
|
+
**CRM(받은문의)** 에 함께 쌓입니다. CRM에서 사이트·폼·업무 유형으로 좁혀 보며,
|
|
11
|
+
상세는 **접수 당시** 항목 이름과 선택지를 보여줍니다.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
| 연동 목적 | SDK | 저장·조회 위치 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| 사이트별·업무별 여러 폼 | `fetchInquiryForm`, `submitFormInquiry` | CRM, 폼별 타입을 유지하는 답변과 접수증 |
|
|
16
|
+
| 이미 연동한 고정 필드 리드 | `submitInquiry` | 기존 CRM 접수 계약 유지 |
|
|
17
|
+
| 방문자가 글·답변을 다시 조회하는 상담 게시판 | `submitSiteInquiry`, `fetchSiteInquiries`, `fetchSiteInquiry` | CMS 상담 게시판, 공개/비밀글·답변 조회 |
|
|
18
|
+
|
|
19
|
+
새로운 연락 요청 폼에는 `submitFormInquiry`를 사용하세요. 이름·사업체명·이메일을
|
|
20
|
+
모든 폼의 필수로 정하지 않습니다. 폼에는 전화 또는 이메일 연락 역할을 지정하며,
|
|
21
|
+
제출할 때 유효한 연락 수단이 하나 이상 필요합니다. 전화만 받는 폼을 위해 가짜
|
|
22
|
+
이메일을 만들 필요가 없습니다.
|
|
23
|
+
|
|
24
|
+
API 키는 블로그 조회와 같은 `rtlk_cust_*` 키를 사이트 서버에서 사용합니다.
|
|
25
|
+
**브라우저에 키를 전달하지 마세요.** 키가 고객사를 식별하고, 사이트에 한정된 키는
|
|
26
|
+
그 사이트에 고정됩니다. 고객사 전체 키에는 `siteId`가 필수입니다. 폼이 해당
|
|
27
|
+
고객사·사이트에 속하지 않으면 `404 not_found`로 거부됩니다.
|
|
28
|
+
|
|
29
|
+
## 같은 사이트에 견적·AS 폼 연결하기
|
|
30
|
+
|
|
31
|
+
1. ROOT-ADMIN에서 사이트의 **문의 폼**을 열어 견적과 AS 폼을 각각 만듭니다.
|
|
32
|
+
2. 견적에는 `sales`, AS에는 `service` 업무 유형을 지정합니다. 항목 키·타입·필수
|
|
33
|
+
조건·개인정보 동의 문구를 저장하고 두 폼의 ID를 서버 설정에 넣습니다.
|
|
34
|
+
3. 각 폼을 `fetchInquiryForm`으로 읽어 해당 `version`과 동의 문구를 보여줍니다.
|
|
35
|
+
제출에는 방문자가 본 버전을 그대로 보냅니다.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// 서버 코드 — 같은 사이트, 서로 다른 폼 ID와 발행 버전
|
|
39
|
+
import { fetchInquiryForm } from "@roottale/cms-client/server";
|
|
40
|
+
|
|
41
|
+
const connection = {
|
|
42
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
43
|
+
siteId: process.env.ROOTTALE_SITE_ID!,
|
|
44
|
+
};
|
|
45
|
+
const [quote, service] = await Promise.all([
|
|
46
|
+
fetchInquiryForm({ ...connection, formId: process.env.ROOTTALE_QUOTE_FORM_ID! }),
|
|
47
|
+
fetchInquiryForm({ ...connection, formId: process.env.ROOTTALE_SERVICE_FORM_ID! }),
|
|
48
|
+
]);
|
|
49
|
+
// quote.version이 2이고 service.version이 5일 수 있습니다.
|
|
50
|
+
// null이면 접수 중지·미등록·접근할 수 없는 폼입니다.
|
|
51
|
+
// definition.privacyConsentText와 definition.fields를 각 폼에 표시합니다.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`fetchInquiryForm`은 `{ formId, siteId, formKey, version, definition }`을 반환합니다.
|
|
55
|
+
항상 현재 정의를 읽으며, `404`는 `null`, 다른 HTTP 오류와 잘못된 정상 응답은
|
|
56
|
+
`CmsApiError`를 던집니다. API 키·네트워크 장애를 ‘폼 없음’으로 처리하지 마세요.
|
|
57
|
+
|
|
58
|
+
항목은 `text/textarea/email/tel/select/multiselect/checkbox/number/date`를
|
|
59
|
+
지원합니다. `requireOneOf: [["trades", "message"]]`처럼 여러 항목 중 하나를
|
|
60
|
+
요구할 수 있습니다. 선택지는 화면의 라벨이 아닌 `value`로 제출하며, 복수 선택은
|
|
61
|
+
문자열 배열, 숫자는 숫자, 체크박스는 boolean으로 보냅니다. 정의·타입·검증의
|
|
62
|
+
공통 계약은 공개 패키지 `@roottale/inquiry-forms`에 있습니다.
|
|
63
|
+
|
|
64
|
+
## 권장 — `submitFormInquiry` Server Action
|
|
65
|
+
|
|
66
|
+
아래 `attempt`는 브라우저가 한 번의 제출을 시작할 때 만든 입력 묶음입니다.
|
|
67
|
+
Server Action에서 폼·사이트 ID를 서버 설정으로 고정하고 API가 발행 버전과 모든
|
|
68
|
+
답변을 검증합니다. 임의 `tenant`, 업무 유형, 라벨을 제출 데이터로 받지 않습니다.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
"use server";
|
|
72
|
+
|
|
73
|
+
import { submitFormInquiry, type SubmitFormInquiryFields } from "@roottale/cms-client/server";
|
|
74
|
+
|
|
75
|
+
export async function submitQuote(
|
|
76
|
+
attempt: Omit<SubmitFormInquiryFields, "siteId" | "placement">,
|
|
77
|
+
) {
|
|
78
|
+
return submitFormInquiry({
|
|
79
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
80
|
+
formId: process.env.ROOTTALE_QUOTE_FORM_ID!,
|
|
81
|
+
fields: {
|
|
82
|
+
...attempt,
|
|
83
|
+
siteId: process.env.ROOTTALE_SITE_ID!,
|
|
84
|
+
placement: "contact/quote",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
견적 폼을 호출하는 입력 예시는 다음과 같습니다. 실제 필드 키는 관리자에 저장한
|
|
91
|
+
정의와 같아야 합니다.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const attempt = {
|
|
95
|
+
version: quote.version,
|
|
96
|
+
answers: {
|
|
97
|
+
phone: "010-1234-5678",
|
|
98
|
+
trades: ["painting", "waterproofing"],
|
|
99
|
+
message: "옥상 방수 견적을 요청합니다.",
|
|
100
|
+
},
|
|
101
|
+
privacyConsent: consentCheckbox.checked,
|
|
102
|
+
idempotencyKey: crypto.randomUUID(), // 새 제출을 시작할 때 한 번만 생성
|
|
103
|
+
};
|
|
104
|
+
const result = await submitQuote(attempt);
|
|
105
|
+
// 응답을 잃었다면 attempt를 그대로 보관한 뒤 submitQuote(attempt)로 재시도합니다.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
실행 가능한 전체 예제는 npm 패키지의 다음 파일에 있습니다.
|
|
109
|
+
|
|
110
|
+
- `examples/nextjs/lib/site-inquiry-forms.ts`: 같은 사이트의 두 폼 ID를 서버 설정에 연결
|
|
111
|
+
- `examples/nextjs/app/contact/page.tsx`: 각 폼의 현재 버전과 정의 조회
|
|
112
|
+
- `examples/nextjs/components/site-inquiry-form.tsx`: 타입별 입력·동의·실패 표시·같은 제출 재시도
|
|
113
|
+
- `examples/nextjs/lib/actions/submit-site-form.ts`: 공개 SDK를 사용하는 Server Action
|
|
114
|
+
|
|
115
|
+
예제의 서버 설정은 `ROOTTALE_API_KEY`, `ROOTTALE_SITE_ID`,
|
|
116
|
+
`ROOTTALE_QUOTE_FORM_ID`, `ROOTTALE_SERVICE_FORM_ID`입니다.
|
|
117
|
+
`ROOTTALE_API_BASE`는 선택입니다. Turnstile 검증을 사용하는 사이트는 폼 안에
|
|
118
|
+
위젯을 연결해 `cf-turnstile-response` 값을 제공하세요.
|
|
119
|
+
|
|
120
|
+
## 접수증과 재시도
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
type SubmitFormInquiryResult =
|
|
124
|
+
| {
|
|
125
|
+
ok: true;
|
|
126
|
+
receipt: { id: string; inquiryNo: number; receivedAt: string; replayed: boolean };
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
ok: false;
|
|
130
|
+
kind: "api" | "transport" | "invalid_response";
|
|
131
|
+
code: string;
|
|
132
|
+
status: number | null;
|
|
133
|
+
message: string;
|
|
134
|
+
fieldErrors: Record<string, string[]>;
|
|
135
|
+
formErrors: string[];
|
|
136
|
+
};
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
성공은 DB 저장을 확인한 접수증이 있을 때만 반환합니다. 같은 폼에 동일한 키와
|
|
140
|
+
내용으로 재시도하면 기존 접수증과 `replayed: true`를 받고 문의·알림이 중복되지
|
|
141
|
+
않습니다. SDK가 자동 재시도하거나 중복 방지 키를 새로 만들지는 않습니다.
|
|
142
|
+
|
|
143
|
+
`transport`·`invalid_response`·서버 `5xx`는 저장 여부가 불확실할 수 있습니다.
|
|
144
|
+
**키와 답변·버전·동의·배치·유입 정보 전체를 함께 유지**하고, 결과를 확인할 때까지
|
|
145
|
+
편집하거나 새 제출로 바꾸지 마세요. Turnstile 토큰은 새 검증 결과로 갱신할 수
|
|
146
|
+
있습니다. 예제는 현재 화면의 메모리에 제출을 보관하므로 확인 전에 새로고침하면
|
|
147
|
+
재시도 정보가 사라집니다. 페이지 이동을 포함한 복구가 필요하면 별도 저장 정책을
|
|
148
|
+
정해야 합니다.
|
|
149
|
+
|
|
150
|
+
| `code` | 처리 |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `validation_error` | `fieldErrors`를 각 입력에, `formErrors`를 폼 전체에 표시 |
|
|
153
|
+
| `form_version_conflict` | 폼을 다시 읽고 변경된 항목·동의 문구를 확인한 후 새로 제출 |
|
|
154
|
+
| `idempotency_key_conflict` | 이미 저장된 제출의 키와 다른 내용. 자동으로 새 키를 만들어 재접수하지 않음 |
|
|
155
|
+
| `not_found` | 해당 키의 고객사·사이트 범위에서 접수 가능한 폼이 없음 |
|
|
156
|
+
| `turnstile_failed` | 보안 확인을 갱신 |
|
|
157
|
+
| `rate_limited` | 잠시 후 같은 제출로 재시도 |
|
|
158
|
+
| `network_error`, `invalid_response` | 동일한 제출로 다시 요청해 접수증 확인 |
|
|
159
|
+
|
|
160
|
+
폼 버전이 바뀌거나 접수가 중지되더라도 **이미 저장된 동일 제출의 재시도**는 기존
|
|
161
|
+
접수증을 돌려줍니다. 과거 접수는 최신 폼 정의로 다시 해석하지 않습니다.
|
|
162
|
+
|
|
163
|
+
## 다중 폼 제출 필드 (`SubmitFormInquiryFields`)
|
|
164
|
+
|
|
165
|
+
| 필드 | 필수 | 설명 |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `siteId` | 고객사 전체 키 사용 시 | 사이트 ID. 사이트 한정 키로 다른 사이트를 지정할 수 없음 |
|
|
168
|
+
| `version` | ✅ | 방문자가 실제 본 발행 버전 |
|
|
169
|
+
| `answers` | ✅ | 항목 키 → 문자열·문자열 배열·숫자·boolean |
|
|
170
|
+
| `privacyConsent` | ✅ | 사용자가 명시 동의한 경우에만 `true` |
|
|
171
|
+
| `idempotencyKey` | ✅ | 1~200자 영문·숫자·`_`·`-`, 제출별 고유 키 |
|
|
172
|
+
| `placement` | | 같은 폼을 여러 위치에 배치한 경우 식별자, 예: `home/footer` |
|
|
173
|
+
| `turnstileToken` | | 사이트에서 사용하는 Turnstile 검증 토큰 |
|
|
174
|
+
| `attribution` | | `readAttribution()` 객체. `null`은 생략 |
|
|
175
|
+
| `journey` | | `readJourney()`의 배열. 기존 `submitInquiry`와 달리 JSON 문자열로 바꾸지 않음 |
|
|
176
|
+
|
|
177
|
+
유입 정보·방문 여정을 수집한다면 폼의 실제 수집 항목과 동의 문구에 반영하세요.
|
|
178
|
+
구조화 답변과 접수 당시 폼·동의 문구는 서버에서 암호화 보존합니다. 기존 리드의
|
|
179
|
+
어트리뷰션·방문 여정 연동 설명은 아래를 참고하세요.
|
|
180
|
+
|
|
181
|
+
## 기존 연동 — `submitInquiry` Server Action
|
|
182
|
+
|
|
183
|
+
기존 리드 API의 고정 필드 계약을 유지할 때 사용합니다. 전화만 받거나 업무별
|
|
184
|
+
항목이 다른 새 폼은 위의 다중 폼 API로 연결하세요.
|
|
14
185
|
|
|
15
186
|
```ts
|
|
16
187
|
// lib/actions/submit-contact.ts
|
|
@@ -30,6 +201,8 @@ export async function submitContact(
|
|
|
30
201
|
): Promise<ContactState> {
|
|
31
202
|
const name = (formData.get("name") as string | null)?.trim() ?? "";
|
|
32
203
|
const phone = (formData.get("phone") as string | null)?.trim() ?? "";
|
|
204
|
+
const email = (formData.get("email") as string | null)?.trim() ?? "";
|
|
205
|
+
const businessName = (formData.get("businessName") as string | null)?.trim() ?? "";
|
|
33
206
|
const message = (formData.get("message") as string | null)?.trim() ?? "";
|
|
34
207
|
const turnstileToken =
|
|
35
208
|
(formData.get("cf-turnstile-response") as string | null)?.trim() ?? "";
|
|
@@ -49,8 +222,8 @@ export async function submitContact(
|
|
|
49
222
|
fields: {
|
|
50
223
|
vertical: "tax", // consulting | medical | tax | legal
|
|
51
224
|
contactName: name,
|
|
52
|
-
businessName
|
|
53
|
-
email
|
|
225
|
+
businessName,
|
|
226
|
+
email,
|
|
54
227
|
phone, // 자동으로 010-1234-5678 형태 포맷됨
|
|
55
228
|
message: message || undefined,
|
|
56
229
|
turnstileToken: turnstileToken || undefined,
|
|
@@ -68,13 +241,13 @@ export async function submitContact(
|
|
|
68
241
|
클라이언트 폼에서는 `useActionState(submitContact, { status: "idle" })`로
|
|
69
242
|
연결합니다.
|
|
70
243
|
|
|
71
|
-
## 필드
|
|
244
|
+
## 기존 리드 필드 (`SubmitInquiryFields`)
|
|
72
245
|
|
|
73
246
|
| 필드 | 필수 | 설명 |
|
|
74
247
|
|---|---|---|
|
|
75
248
|
| `vertical` | ✅ | `consulting` \| `medical` \| `tax` \| `legal` |
|
|
76
249
|
| `contactName` | ✅ | 이름 |
|
|
77
|
-
| `businessName` | ✅ | 사업체명
|
|
250
|
+
| `businessName` | ✅ | 사업체명 |
|
|
78
251
|
| `email` | ✅ | 이메일 (`.+@.+\..+`) |
|
|
79
252
|
| `phone` | ✅ | 전화번호 (자동 한국식 포맷) |
|
|
80
253
|
| `privacyConsent` | ✅ | 개인정보 수집·이용 동의 — 반드시 사용자 명시 동의 |
|
package/docs/overview.md
CHANGED
|
@@ -17,7 +17,7 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
17
17
|
4. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
|
|
18
18
|
5. `seo.md` — RSS·사이트맵·동적 OG 이미지
|
|
19
19
|
6. `theme-and-settings.md` — ROOT-ANALYTICS 연결 (권장)
|
|
20
|
-
7. `inquiries.md` —
|
|
20
|
+
7. `inquiries.md` — 사이트별 여러 문의 폼과 유입·여정 저장 (선택)
|
|
21
21
|
8. `menus.md` — 어드민 관리 네비게이션 (선택)
|
|
22
22
|
|
|
23
23
|
```
|
|
@@ -38,7 +38,8 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
38
38
|
| 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
|
|
39
39
|
| 발행 글·페이지 검색 | `searchPosts` / `resolveSearchHitPath` |
|
|
40
40
|
| 발행 웹훅 서명 검증 + 캐시 갱신 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요). 설정 저장을 즉시 반영하려면 `revalidateTag` 주입 필수 — `revalidation-webhooks.md` §1 |
|
|
41
|
-
|
|
|
41
|
+
| 사이트별 여러 문의 폼 접수 | `fetchInquiryForm`·`submitFormInquiry` — 키의 고객사·사이트 안에서 폼 ID와 버전으로 접수 |
|
|
42
|
+
| 기존 고정 필드 리드 접수 | `submitInquiry` — 기존 연동 계약 유지 |
|
|
42
43
|
| 테마·블로그 표시·ROOT-ANALYTICS 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
|
|
43
44
|
| 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
|
|
44
45
|
| 글 하단 공통 블록 조회 | `fetchSitePatterns` + `selectSitePatternForSlot` (`RootTaleBlogPost`는 자동) |
|
|
@@ -64,7 +65,7 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
64
65
|
| `blog.md` | 블로그 목록/상세 페이지 구현 (컴포넌트 또는 직접 fetch) |
|
|
65
66
|
| `search.md` | 글·페이지 통합 검색, 실제 공개 주소 계산, 보안·캐시·장애 처리 |
|
|
66
67
|
| `revalidation-webhooks.md` | 발행 웹훅으로 near-real-time 캐시 갱신 |
|
|
67
|
-
| `inquiries.md` |
|
|
68
|
+
| `inquiries.md` | 다중 폼·접수증·CRM 연동과 기존 리드 API |
|
|
68
69
|
| `menus.md` | 메뉴(네비게이션) — 어드민 "디자인 > 메뉴" 트리를 헤더/푸터에 렌더 |
|
|
69
70
|
| `seo.md` | RSS 피드, 사이트맵, JSON-LD, 동적 OG 이미지, 공개 검색, fleet 프로브 |
|
|
70
71
|
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, 공통 블록(글 하단), ROOT-ANALYTICS |
|
|
@@ -52,7 +52,7 @@ API 키나 예약 작업을 설정할 필요는 없습니다.
|
|
|
52
52
|
하단에 맞추고 닫기·오늘 그만 보기·슬라이드 조작 영역을 확보합니다.
|
|
53
53
|
|
|
54
54
|
닫기와 표시 기록은 해당 사이트의 쿠키에 저장합니다. 일반 닫기는 현재 묶음을 함께
|
|
55
|
-
닫고 항목의 재표시 설정을 따릅니다. `오늘 그만 보기`는 해당 위치 전체를 사이트
|
|
55
|
+
닫고 항목의 재표시 설정을 따릅니다. `매번 표시(always)`의 일반 닫기는 현재 화면에만 적용되며 새로고침·재접속하면 다시 표시합니다. 매번 표시로 변경한 항목은 이전 일반 닫기·방문 기록에 막히지 않습니다. `오늘 그만 보기`는 해당 위치 전체를 사이트
|
|
56
56
|
시간대의 다음 자정까지 숨기므로 그날 추가된 슬라이드도 다시 열리지 않습니다.
|
|
57
57
|
방문 중 한 번은 세션 쿠키를 사용하며, 이전 runtime의 localStorage/sessionStorage
|
|
58
58
|
기록도 읽어서 기존 숨김 선택을 존중합니다. 로그인 계정이나 다른 기기에 공유되지는
|
|
@@ -165,6 +165,43 @@ Provider와 조회 경로를 함께 업데이트한 뒤 v2 계약을 등록합
|
|
|
165
165
|
|
|
166
166
|
## 작성 중 미리보기
|
|
167
167
|
|
|
168
|
+
ROOT-ADMIN의 편집 미리보기는 고객 홈페이지를 PC 1280×900 또는 모바일 390×844
|
|
169
|
+
iframe으로 열고, 저장 전 내용을 해당 사이트의 실제 `RootTaleExposureSlot`에 전달합니다.
|
|
170
|
+
사이트 CSS·폰트·헤더·닫기 조작부를 그대로 사용합니다. 편집 칸 너비에 맞춰 전체 화면을
|
|
171
|
+
축소하므로 관리자 브라우저 너비가 사이트의 모바일 분기점을 바꾸지 않습니다.
|
|
172
|
+
|
|
173
|
+
이 연결은 사이트와 관리자 모두 **실제 사이트 미리보기 기능이 포함된 renderer 릴리스**를
|
|
174
|
+
사용해야 합니다(0.63.0에는 미포함). Provider가 연결을 담당하므로 새 서버 API나 CMS 키
|
|
175
|
+
전달은 필요 없습니다. 이전 버전·iframe 차단·누락 슬롯·지원하지 않는 형식에서는
|
|
176
|
+
관리자가 연결 대기를 표시하며, 공통 디자인을 실제 사이트 화면으로 대신 표시하지 않습니다.
|
|
177
|
+
|
|
178
|
+
기본 허용 편집기 출처는 `https://admin.roottale.com`입니다. 자체 관리자나 로컬 검수는
|
|
179
|
+
사이트 코드의 `previewOrigins`에 정확한 출처를 추가합니다. 와일드카드는 사용하지 않습니다.
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
<RootTaleExposureProvider
|
|
183
|
+
endpoint="/api/exposures"
|
|
184
|
+
pathname={pathname ?? "/"}
|
|
185
|
+
slots={EXPOSURE_SLOTS}
|
|
186
|
+
previewOrigins={["https://editor.example.com"]}
|
|
187
|
+
>
|
|
188
|
+
{children}
|
|
189
|
+
</RootTaleExposureProvider>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
사이트의 `frame-ancestors`·`X-Frame-Options`가 관리자 임베딩을 허용해야 합니다.
|
|
193
|
+
사이트가 이를 차단한다면 허용할 관리자 출처만 명시적으로 설정합니다. 미리보기용 URL의
|
|
194
|
+
fragment에는 연결 식별자와 편집기 출처만 들어가고 초안 내용·CMS 키는 포함하지 않습니다.
|
|
195
|
+
입력은 허용된 부모 창·출처·세션을 확인한 후 메모리에서만 처리합니다.
|
|
196
|
+
|
|
197
|
+
편집 미리보기는 현재 한 항목을 예약·스크롤 대기 없이 표시합니다. 기기 조건·예약 시각과
|
|
198
|
+
다른 발행 항목을 합친 노출 판정 검증은 아래 결정 API로 구분합니다. 기존 숨김 쿠키는
|
|
199
|
+
무시하며 새 표시·닫기 기록을 남기지 않습니다. 링크 이동·사이트 폼 제출을 막고,
|
|
200
|
+
사이트에 포함된 자체 분석 스크립트 등은 사이트의 별도 미리보기 정책을 따릅니다.
|
|
201
|
+
팝업이 편집 중인 입력의 초점을 가져가지 않으며 `다시 보기`로 닫은 안내를 다시 확인합니다.
|
|
202
|
+
|
|
203
|
+
### 독립 콘텐츠 렌더링
|
|
204
|
+
|
|
168
205
|
`RootTaleExposurePreview`는 한 항목의 실제 콘텐츠를 표시합니다. 여러 항목을 함께
|
|
169
206
|
확인할 때는 `RootTaleExposureCarouselPreview`에 공개 DTO 형태의 `campaigns`와
|
|
170
207
|
`placement`, `device`를 전달합니다. 두 미리보기 모두 API 조회·방문 기록·숨김 쿠키를
|
|
@@ -196,3 +233,5 @@ npx @roottale/cms-mcp cli exposures preview --input-file preview.json
|
|
|
196
233
|
`exposures:write`는 작성·수정안 저장에, `exposures:publish`는 공개본 변경에 필요합니다.
|
|
197
234
|
기존 `PATCH /v1/cms/exposures/{id}`는 공개본 수정 의미를 유지하므로 수정안 저장에는
|
|
198
235
|
사용하지 않습니다. 다른 고객사·사이트의 미디어는 연결할 수 없습니다.
|
|
236
|
+
|
|
237
|
+
여러 팝업의 사이트별 높이 조절에는 `.rt-exposure-sizer`를 사용할 수 있습니다. 기본 스타일은 숨김이며, 이 영역은 접근성과 상호작용에서 제외됩니다. 같은 너비의 콘텐츠를 겹쳐 배치하면 가장 높은 콘텐츠 기준의 공통 높이를 확보할 수 있습니다. 팝업은 이미지 로딩 후 표시합니다.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: 테마·블로그 표시·ROOT-ANALYTICS 설정
|
|
3
|
-
description:
|
|
3
|
+
description: 고객 운영 정보와 사이트 코드의 디자인 설정을 분리하고 기존 설정 API를 연동
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 테마·블로그 표시·ROOT-ANALYTICS 설정
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
고객이 직접 수정할 콘텐츠·운영 정보는 CMS에서 조회하고, 디자인·레이아웃은
|
|
9
|
+
사이트 코드에서 관리합니다. 기존 사이트의 호환용 디자인 API는 유지됩니다. 모두
|
|
9
10
|
`@roottale/cms-client/server`에서 제공하며 같은 API 키를 사용합니다.
|
|
10
11
|
|
|
11
12
|
## 저장 즉시 반영 — 캐시 이름표(`tags`)
|
|
@@ -28,7 +29,13 @@ description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵
|
|
|
28
29
|
쪽(웹훅 수신 라우트) 배선은 `revalidation-webhooks.md` §1 "설정 저장"을
|
|
29
30
|
따르세요 — **양쪽을 다 해야** 즉시 반영이 됩니다.
|
|
30
31
|
|
|
31
|
-
## 디자인 토큰 — fetchTheme
|
|
32
|
+
## 기존 디자인 토큰 연동 — fetchTheme
|
|
33
|
+
|
|
34
|
+
신규 사이트의 토큰은 프로젝트 CSS·코드에 둡니다. `RootTaleBlogPost`·
|
|
35
|
+
`RootTaleBlogList`·`RootTalePage`·`RootTaleBlogCategories`에 `theme={null}`을 주면
|
|
36
|
+
원격 테마 조회와 CSS 변수 주입을 생략합니다. 명시한 테마 객체는 코드 값을 쓰고,
|
|
37
|
+
prop을 생략하면 기존 호환 동작으로 원격 테마를 조회합니다.
|
|
38
|
+
아래 API는 아직 코드로 이전하지 않은 기존 연동에 사용합니다.
|
|
32
39
|
|
|
33
40
|
```ts
|
|
34
41
|
import { THEME_CACHE_TAG, fetchTheme } from "@roottale/cms-client/server";
|
|
@@ -45,7 +52,9 @@ const theme = await fetchTheme({
|
|
|
45
52
|
|
|
46
53
|
## 상단 메뉴 — theme.siteNav
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
상단 메뉴 구조는 제작자가 관리하는 설정입니다. 고객 콘텐츠 편집 범위에 넣지
|
|
56
|
+
않으며 신규 사이트는 라우트와 함께 코드에 둡니다. 기존 연동은 같은 `fetchTheme`
|
|
57
|
+
응답에 개발자용 어드민 메뉴에서 저장한 GNB
|
|
49
58
|
구조가 함께 담깁니다. 별도 호출이 없고 테마와 같은 캐시 태그로 무효화됩니다.
|
|
50
59
|
|
|
51
60
|
```ts
|
|
@@ -131,11 +140,16 @@ const settings = await fetchBlogSettings({
|
|
|
131
140
|
// 검색 제목·설명, null 이면 사이트 이름·사이트 설명으로 폴백) · logoUrl ·
|
|
132
141
|
// faviconUrl · defaultOgImageUrl — 사이트 <head>/OG 폴백 (seo.md 참고)
|
|
133
142
|
|
|
134
|
-
// 글 단위 오버라이드(metaJson)와 합성해
|
|
135
|
-
const display = resolvePostDisplay(
|
|
143
|
+
// 글 단위 오버라이드(metaJson)와 합성해 노출 여부 계산
|
|
144
|
+
const display = resolvePostDisplay(post, settings);
|
|
145
|
+
// 자체 화면의 배치는 코드가 정합니다. display.tocPosition은 호환용 값입니다.
|
|
146
|
+
const tocPosition = "inline";
|
|
136
147
|
```
|
|
137
148
|
|
|
138
|
-
`RootTaleBlogPost
|
|
149
|
+
`RootTaleBlogPost`는 노출 여부 설정을 자동 반영하되, 목차 배치는 코드 prop
|
|
150
|
+
`tocPosition="inline" | "sidebar"`를 명시하면 CMS 배치보다 우선합니다. 생략하면
|
|
151
|
+
기존 CMS 배치를 유지하므로 기존 사이트의 화면이 바뀌지 않습니다. 관리 주체를
|
|
152
|
+
코드로 옮길 때 현재 배치 값을 명시하고 발행 화면과 미리보기의 prop을 일치시킵니다. 작성자
|
|
139
153
|
사진의 초점은 글에 연결된 작성자 콘텐츠 값을 적용하고, 사진 모양은 사이트의
|
|
140
154
|
시멘틱 토큰/CSS를 따릅니다. 레거시 `postCta`는 아래 공통 블록이 없는 기존 글에서만
|
|
141
155
|
자동 반영되며, 공통 블록이 배치되면 함께 표시되지 않습니다.
|
|
@@ -177,15 +191,39 @@ const footer = selectSitePatternForSlot(post?.patternSlots, patterns);
|
|
|
177
191
|
```tsx
|
|
178
192
|
// 자체 글 화면 + 공용 렌더러 조합
|
|
179
193
|
import { RootTalePostPattern } from "@roottale/cms-renderer-next/server";
|
|
180
|
-
<RootTalePostPattern apiKey={apiKey} post={post} />
|
|
194
|
+
<RootTalePostPattern apiKey={apiKey} post={post} presentation={null} />
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
블록의 문구·연락처·링크는 CMS에 두고 카드·버튼·색은 사이트 코드에 둡니다.
|
|
198
|
+
`RootTalePostPattern.presentation` 또는 `RootTaleBlogPost.footerPatternPresentation`에
|
|
199
|
+
아래 값을 지정하세요.
|
|
200
|
+
|
|
201
|
+
| 값 | 동작 |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `null` | 원격 디자인의 data 속성·CSS 변수 주입을 생략하고 사이트 CSS 사용 |
|
|
204
|
+
| `CmsSitePatternPresentation` 객체 | 원격 디자인 전체를 코드 객체로 대체(부분 병합 아님) |
|
|
205
|
+
| 생략 | 기존 연동 호환을 위해 원격 `pattern.presentation` 사용 |
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
<RootTaleBlogPost
|
|
209
|
+
apiKey={apiKey}
|
|
210
|
+
slugOrId={slug}
|
|
211
|
+
tocPosition="inline"
|
|
212
|
+
theme={null}
|
|
213
|
+
footerPatternPresentation={{
|
|
214
|
+
layout: "card",
|
|
215
|
+
background: "#f8f9fa",
|
|
216
|
+
linkStyle: "buttons",
|
|
217
|
+
buttonColors: ["#03c75a", "#1a1a1a"],
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
181
220
|
```
|
|
182
221
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
`RootTaleBlogPost`·`RootTalePostPattern` 은 이미 그렇게 그립니다.
|
|
222
|
+
객체를 지정하면 `data-pattern-layout`, `data-pattern-link-style`,
|
|
223
|
+
`--rt-pattern-bg`, `--rt-pattern-btn-1..n`으로 전달됩니다. `null`이어도 블록 본문과
|
|
224
|
+
`data-pattern-key`·`data-pattern-slot`은 유지되므로 사이트 CSS에서 선택할 수 있습니다.
|
|
225
|
+
자체 렌더러도 `sitePatternPresentationAttributes`에 CMS 값 대신 프로젝트가 소유한
|
|
226
|
+
디자인 객체를 넘기세요. 기존 `presentation` API 데이터는 삭제하지 않습니다.
|
|
189
227
|
|
|
190
228
|
블록 목록은 `rt-site-patterns` 캐시 이름표를 가지며, 어드민에서 블록·배치 규칙을
|
|
191
229
|
저장하면 `theme.updated` 웹훅이 다른 설정과 함께 지웁니다(`createRevalidateRoute`
|
|
@@ -85,6 +85,9 @@ export default async function PostPage({ params }: Props) {
|
|
|
85
85
|
slugOrId={post.id}
|
|
86
86
|
showTitle={false}
|
|
87
87
|
showTableOfContents
|
|
88
|
+
tocPosition="inline"
|
|
89
|
+
theme={null}
|
|
90
|
+
footerPatternPresentation={null}
|
|
88
91
|
relatedPostsCount={3}
|
|
89
92
|
// opt-in — 시각 브레드크럼 + BreadcrumbList JSON-LD. siteUrl 없으면
|
|
90
93
|
// 시각 브레드크럼만(JSON-LD 미emit).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SiteInquiryForm } from "../../components/site-inquiry-form";
|
|
2
|
+
import { getSiteInquiryForms } from "../../lib/site-inquiry-forms";
|
|
3
|
+
|
|
4
|
+
export default async function ContactPage() {
|
|
5
|
+
// 같은 ROOTTALE_SITE_ID에 견적·AS 폼을 각각 등록한다.
|
|
6
|
+
// 예: 견적 version 2, AS version 5. 번호를 하드코딩하거나 서로 공유하지 않는다.
|
|
7
|
+
const { quote, service } = await getSiteInquiryForms();
|
|
8
|
+
return (
|
|
9
|
+
<main>
|
|
10
|
+
<h1>문의하기</h1>
|
|
11
|
+
{quote ? <SiteInquiryForm formKey="quote" form={quote} /> : <p>견적 접수가 잠시 중지되었습니다.</p>}
|
|
12
|
+
{service ? <SiteInquiryForm formKey="service" form={service} /> : <p>AS 접수가 잠시 중지되었습니다.</p>}
|
|
13
|
+
</main>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -62,6 +62,9 @@ export default async function PostPreviewPage({ params, searchParams }: Props) {
|
|
|
62
62
|
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
63
63
|
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
64
64
|
previewToken={token}
|
|
65
|
+
tocPosition="inline"
|
|
66
|
+
theme={null}
|
|
67
|
+
footerPatternPresentation={null}
|
|
65
68
|
relatedPostsCount={3}
|
|
66
69
|
breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
|
|
67
70
|
/>
|
|
@@ -8,6 +8,9 @@ import { GlobalPopup } from "./global-popup";
|
|
|
8
8
|
export function SiteExposures({ children }: { children: React.ReactNode }) {
|
|
9
9
|
const pathname = usePathname();
|
|
10
10
|
return (
|
|
11
|
+
// ROOT-ADMIN의 실제 사이트 미리보기는 이 Provider와 아래 실제 Slot을 재사용한다.
|
|
12
|
+
// 자체 관리자·로컬 검수는 previewOrigins={["https://editor.example.com"]}처럼
|
|
13
|
+
// 정확한 출처를 추가한다. canonical admin.roottale.com은 기본 허용된다.
|
|
11
14
|
<RootTaleExposureProvider endpoint="/api/exposures" pathname={pathname ?? "/"} slots={EXPOSURE_SLOTS} homePaths={EXPOSURE_HOME_PATHS}>
|
|
12
15
|
<RootTaleExposureSlot slotKey="site-banner" placement="top" allowedVariants={["card"]} />
|
|
13
16
|
{children}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition, type FormEvent, type ReactNode } from "react";
|
|
4
|
+
import { readAttribution } from "@roottale/cms-client/attribution";
|
|
5
|
+
import type { InquiryAnswer, InquiryFormField, PublishedInquiryForm, SubmitFormInquiryResult } from "@roottale/cms-client/server";
|
|
6
|
+
|
|
7
|
+
import { submitSiteForm, type SiteFormAttempt } from "../lib/actions/submit-site-form";
|
|
8
|
+
import type { SiteFormKey } from "../lib/site-inquiry-forms";
|
|
9
|
+
|
|
10
|
+
// 이 컴포넌트는 폼 정의만 받는다. 브라우저에 API 키를 전달하지 않는다.
|
|
11
|
+
// children에 사이트의 Turnstile 위젯을 넣으면 cf-turnstile-response를 함께 보낸다.
|
|
12
|
+
export function SiteInquiryForm({
|
|
13
|
+
formKey, form, children,
|
|
14
|
+
}: { formKey: SiteFormKey; form: PublishedInquiryForm; children?: ReactNode }) {
|
|
15
|
+
const [result, setResult] = useState<SubmitFormInquiryResult | null>(null);
|
|
16
|
+
const [attempt, setAttempt] = useState<SiteFormAttempt | null>(null);
|
|
17
|
+
const [pending, startTransition] = useTransition();
|
|
18
|
+
const versionChanged = result?.ok === false && result.code === "form_version_conflict";
|
|
19
|
+
|
|
20
|
+
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
const data = new FormData(event.currentTarget);
|
|
23
|
+
// 결과가 불명확하면 answers/version/동의/attribution과 key 모두 유지한다.
|
|
24
|
+
// 보안 토큰만 새 위젯 결과로 교체할 수 있다.
|
|
25
|
+
const nextAttempt: SiteFormAttempt = {
|
|
26
|
+
...(attempt ?? {
|
|
27
|
+
version: form.version,
|
|
28
|
+
answers: collectAnswers(form.definition.fields, data),
|
|
29
|
+
privacyConsent: data.get("privacyConsent") === "on",
|
|
30
|
+
idempotencyKey: crypto.randomUUID(),
|
|
31
|
+
attribution: readAttribution(),
|
|
32
|
+
}),
|
|
33
|
+
turnstileToken: text(data.get("cf-turnstile-response")) || undefined,
|
|
34
|
+
};
|
|
35
|
+
setAttempt(nextAttempt);
|
|
36
|
+
startTransition(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const response = await submitSiteForm(formKey, nextAttempt);
|
|
39
|
+
setResult(response);
|
|
40
|
+
// 저장되지 않았음이 명확한 입력/인증 오류만 편집을 다시 허용한다.
|
|
41
|
+
// 409 충돌과 5xx, 응답 유실은 같은 제출을 보존한다.
|
|
42
|
+
// 앞선 응답이 불확실했다면 뒤의 인증/검증 실패로 기존 제출을 지우지 않는다.
|
|
43
|
+
if (attempt === null && !response.ok && response.kind === "api" && response.status !== null
|
|
44
|
+
&& [400, 401, 403, 404, 422, 429].includes(response.status)) setAttempt(null);
|
|
45
|
+
} catch {
|
|
46
|
+
// 사이트 Server Action의 응답 자체가 끊겨도 기존 key를 유지한다.
|
|
47
|
+
setResult({
|
|
48
|
+
ok: false, kind: "transport", code: "network_error", status: null,
|
|
49
|
+
message: "접수 결과를 확인하지 못했습니다. 같은 내용으로 다시 시도해주세요.",
|
|
50
|
+
fieldErrors: {}, formErrors: [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (result?.ok) return (
|
|
57
|
+
<section aria-live="polite">
|
|
58
|
+
<h2>{form.definition.title}</h2>
|
|
59
|
+
<p>{form.definition.successMessage}</p>
|
|
60
|
+
<p>접수번호: {result.receipt.inquiryNo}</p>
|
|
61
|
+
</section>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<form onSubmit={onSubmit}>
|
|
66
|
+
<h2>{form.definition.title}</h2>
|
|
67
|
+
<fieldset disabled={pending || attempt !== null}>
|
|
68
|
+
<legend>문의 내용</legend>
|
|
69
|
+
{form.definition.fields.map((field) => (
|
|
70
|
+
<div key={field.key}>
|
|
71
|
+
<label htmlFor={`${form.formId}-${field.key}`}>{field.label}{field.required ? " (필수)" : ""}</label>
|
|
72
|
+
<AnswerField field={field} id={`${form.formId}-${field.key}`} />
|
|
73
|
+
{field.helpText && <p>{field.helpText}</p>}
|
|
74
|
+
{result?.fieldErrors[field.key]?.map((error) => <p key={error} role="alert">{error}</p>)}
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
<label>
|
|
78
|
+
<input name="privacyConsent" type="checkbox" required />
|
|
79
|
+
{form.definition.privacyConsentText}
|
|
80
|
+
</label>
|
|
81
|
+
{form.definition.privacyPolicyUrl && <a href={form.definition.privacyPolicyUrl}>개인정보 처리방침</a>}
|
|
82
|
+
</fieldset>
|
|
83
|
+
{children}
|
|
84
|
+
{result && <p role="alert">{result.message}</p>}
|
|
85
|
+
{result?.formErrors.map((error) => <p key={error} role="alert">{error}</p>)}
|
|
86
|
+
{versionChanged ? (
|
|
87
|
+
<button type="button" onClick={() => window.location.reload()}>변경된 항목과 동의 문구 다시 확인</button>
|
|
88
|
+
) : (
|
|
89
|
+
<button type="submit" disabled={pending}>{pending ? "접수 중…" : attempt ? "같은 내용으로 재시도" : "문의 접수"}</button>
|
|
90
|
+
)}
|
|
91
|
+
</form>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function AnswerField({ field, id }: { field: InquiryFormField; id: string }) {
|
|
96
|
+
const common = { id, name: field.key, required: field.required };
|
|
97
|
+
if (field.type === "textarea") return <textarea {...common} maxLength={field.maxLength} />;
|
|
98
|
+
if (field.type === "select" || field.type === "multiselect") return (
|
|
99
|
+
<select {...common} multiple={field.type === "multiselect"}>
|
|
100
|
+
{field.type === "select" && <option value="">선택해주세요</option>}
|
|
101
|
+
{field.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
102
|
+
</select>
|
|
103
|
+
);
|
|
104
|
+
return <input {...common} type={field.type} min={field.min} max={field.max} maxLength={field.maxLength} />;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collectAnswers(fields: InquiryFormField[], data: FormData): Record<string, InquiryAnswer> {
|
|
108
|
+
const answers: Record<string, InquiryAnswer> = {};
|
|
109
|
+
for (const field of fields) {
|
|
110
|
+
if (field.type === "multiselect") answers[field.key] = data.getAll(field.key).filter((value): value is string => typeof value === "string");
|
|
111
|
+
else if (field.type === "checkbox") answers[field.key] = data.has(field.key);
|
|
112
|
+
else {
|
|
113
|
+
const value = text(data.get(field.key));
|
|
114
|
+
if (value !== "") answers[field.key] = field.type === "number" ? Number(value) : value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return answers;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function text(value: FormDataEntryValue | null): string {
|
|
121
|
+
return typeof value === "string" ? value : "";
|
|
122
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 기존 고정 필드 리드 연동을 유지하는 예제. 새 사이트와 다중 폼은 submit-site-form.ts 참고.
|
|
2
2
|
// 클라이언트 폼에서 useActionState(submitContact, { status: "idle" })로 연결.
|
|
3
3
|
"use server";
|
|
4
4
|
|
|
@@ -16,6 +16,8 @@ export async function submitContact(
|
|
|
16
16
|
): Promise<ContactState> {
|
|
17
17
|
const name = (formData.get("name") as string | null)?.trim() ?? "";
|
|
18
18
|
const phone = (formData.get("phone") as string | null)?.trim() ?? "";
|
|
19
|
+
const email = (formData.get("email") as string | null)?.trim() ?? "";
|
|
20
|
+
const businessName = (formData.get("businessName") as string | null)?.trim() ?? "";
|
|
19
21
|
const field = (formData.get("field") as string | null)?.trim() ?? "";
|
|
20
22
|
const message = (formData.get("message") as string | null)?.trim() ?? "";
|
|
21
23
|
const turnstileToken =
|
|
@@ -39,8 +41,8 @@ export async function submitContact(
|
|
|
39
41
|
fields: {
|
|
40
42
|
vertical: "consulting", // 사이트 업종에 맞게: consulting | medical | tax | legal
|
|
41
43
|
contactName: name,
|
|
42
|
-
businessName
|
|
43
|
-
email
|
|
44
|
+
businessName,
|
|
45
|
+
email,
|
|
44
46
|
phone,
|
|
45
47
|
consultationField: field || undefined,
|
|
46
48
|
message: message || undefined,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { submitFormInquiry, type SubmitFormInquiryFields, type SubmitFormInquiryResult } from "@roottale/cms-client/server";
|
|
4
|
+
|
|
5
|
+
import { siteFormConnection, type SiteFormKey } from "../site-inquiry-forms";
|
|
6
|
+
|
|
7
|
+
export type SiteFormAttempt = Omit<SubmitFormInquiryFields, "siteId" | "placement">;
|
|
8
|
+
|
|
9
|
+
// formKey와 answers는 신뢰하지 않는다. 사이트/폼 ID는 서버 설정에서 고르고,
|
|
10
|
+
// API가 key의 tenant/site, 발행 버전, 동의와 모든 답변을 다시 검증한다.
|
|
11
|
+
export async function submitSiteForm(
|
|
12
|
+
formKey: SiteFormKey,
|
|
13
|
+
attempt: SiteFormAttempt,
|
|
14
|
+
): Promise<SubmitFormInquiryResult> {
|
|
15
|
+
if (formKey !== "quote" && formKey !== "service") throw new Error("Unknown inquiry form");
|
|
16
|
+
const connection = siteFormConnection(formKey);
|
|
17
|
+
return submitFormInquiry({
|
|
18
|
+
...connection,
|
|
19
|
+
fields: {
|
|
20
|
+
...attempt,
|
|
21
|
+
siteId: connection.siteId,
|
|
22
|
+
placement: `contact/${formKey}`,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 서버 전용. 하나의 사이트에 등록한 두 폼은 서로 다른 ID와 발행 버전을 갖는다.
|
|
2
|
+
import { fetchInquiryForm } from "@roottale/cms-client/server";
|
|
3
|
+
|
|
4
|
+
export type SiteFormKey = "quote" | "service";
|
|
5
|
+
|
|
6
|
+
export function siteFormConnection(key: SiteFormKey) {
|
|
7
|
+
const apiKey = process.env.ROOTTALE_API_KEY;
|
|
8
|
+
const siteId = process.env.ROOTTALE_SITE_ID;
|
|
9
|
+
const formId = key === "quote"
|
|
10
|
+
? process.env.ROOTTALE_QUOTE_FORM_ID
|
|
11
|
+
: process.env.ROOTTALE_SERVICE_FORM_ID;
|
|
12
|
+
if (!apiKey || !siteId || !formId) throw new Error(`Missing inquiry form configuration: ${key}`);
|
|
13
|
+
return { apiKey, siteId, formId, baseUrl: process.env.ROOTTALE_API_BASE };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getSiteInquiryForms() {
|
|
17
|
+
const [quote, service] = await Promise.all([
|
|
18
|
+
fetchInquiryForm(siteFormConnection("quote")),
|
|
19
|
+
fetchInquiryForm(siteFormConnection("service")),
|
|
20
|
+
]);
|
|
21
|
+
return { quote, service };
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# 미디어 업로드 결과와 재시도
|
|
2
|
+
|
|
3
|
+
미디어를 연동할 때는 [업로드 API](../../docs/api-reference.md#미디어-업로드-api)의
|
|
4
|
+
3단계를 따릅니다. 업로드 URL 발급 응답의 `r2_key`를 완료 요청용으로 보관하고,
|
|
5
|
+
공개 페이지에는 완료 응답의 `url`을 사용합니다. 보존이 활성화된 환경에서는
|
|
6
|
+
임시 업로드 경로와 최종 `r2_key`가 다릅니다.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// 인증된 서버 측 작업에서 실행합니다. API 키를 클라이언트 번들에 넣지 않습니다.
|
|
10
|
+
// uploadRequest는 1단계 응답, fileInfo는 1단계에 보낸 파일 정보입니다.
|
|
11
|
+
const completionBody = {
|
|
12
|
+
...fileInfo,
|
|
13
|
+
r2_key: uploadRequest.r2_key,
|
|
14
|
+
};
|
|
15
|
+
const completed = await fetch(`${apiBase}/v1/cms/media/uploads/complete`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
18
|
+
body: JSON.stringify(completionBody),
|
|
19
|
+
});
|
|
20
|
+
if (completed.status === 503) {
|
|
21
|
+
// 같은 completionBody를 보관해 잠시 뒤 재시도합니다.
|
|
22
|
+
// 이때 새 업로드를 발급하거나 임시 경로로 공개 URL을 만들지 않습니다.
|
|
23
|
+
throw new Error("파일 보존 확인이 지연되고 있습니다. 같은 업로드를 다시 완료해 주세요.");
|
|
24
|
+
}
|
|
25
|
+
if (!completed.ok) throw new Error("미디어 등록에 실패했습니다.");
|
|
26
|
+
const media = await completed.json(); // 신규201 또는 멱등 재시도200
|
|
27
|
+
// 글 연결에는 media.id, 본문 이미지에는 media.url을 사용합니다.
|
|
28
|
+
```
|
package/package.json
CHANGED