@roottale/cms-renderer-next 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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # @roottale/cms-renderer-next
2
2
 
3
+ ## 0.64.0
4
+
5
+ ### Minor Changes
6
+
7
+ - faf7bb5: 관리자 편집 내용을 고객 사이트의 실제 팝업·배너 슬롯에서 미리 볼 수 있도록 출처·창·세션을 검증하는 iframe 연결을 추가합니다. 실제 CSS·배치·닫기 설정을 재사용하며 노출 판정 조회와 숨김 기록 없이 편집합니다. 미리보기 연결과 배포 조건을 문서·Next.js 예제에 설명합니다.
8
+
9
+ ### Patch Changes
10
+
11
+ - c2222ee: 매번 표시 안내의 일반 닫기를 현재 화면에만 적용하고, 이전 세션 기록이 설정 변경 후 재표시를 막지 않도록 수정합니다. 오늘 그만 보기의 자정 만료는 유지합니다.
12
+ - cbcea75: 단일 이미지 팝업은 이미지 로딩이 끝난 뒤 표시하고 포커스를 이동해, 원본 비율 높이로 렌더링해도 로딩 중 창이 움직이지 않도록 합니다.
13
+ - 56a9bb5: 여러 팝업의 최대 콘텐츠 높이를 측정할 수 있는 비상호작용 영역을 제공하고 이미지 로딩 후 창을 표시합니다.
14
+ - 5bb9e98: 팝업 초기 포커스를 닫기 버튼 대신 대화상자로 옮겨 불필요한 버튼 강조를 없애고 키보드 탐색을 유지합니다.
15
+ - Updated dependencies [5ccf503]
16
+ - @roottale/cms-client@0.64.0
17
+ - @roottale/cms-core@0.64.0
18
+
19
+ ## 0.63.0
20
+
21
+ ### Minor Changes
22
+
23
+ - a858d8e: 목차 배치를 사이트 코드의 `RootTaleBlogPost.tocPosition`으로 관리할 수 있습니다.
24
+ 명시한 값은 CMS 설정보다 우선하며, 생략한 기존 연동은 CMS 배치를 유지합니다.
25
+ 현재 배치를 prop으로 명시하면 실제 화면을 바꾸지 않고 관리 주체를 코드로 옮깁니다.
26
+
27
+ 공통 블록 디자인은 `RootTaleBlogPost.footerPatternPresentation`과
28
+ `RootTalePostPattern.presentation`으로 전체 대체할 수 있습니다. `null`은 원격
29
+ 디자인 주입을 생략하고 사이트 CSS에 맡기며, 생략한 기존 연동은 원격값을 유지합니다.
30
+ 고객 콘텐츠와 제작 디자인을 분리하는 문서·발행/미리보기 예제를 함께 갱신합니다.
31
+
32
+ 공개 `RootTaleCssVars` 타입의 비공개 UI 패키지 참조도 제거해 독립 소비자의
33
+ strict 타입 검사를 지원합니다.
34
+
35
+ 섹션 구성·디자인 변경 검사 함수를 공개 CMS core에서 공유해 편집기와 저장 단계가
36
+ 같은 고객 편집 경계를 적용합니다.
37
+
38
+ ### Patch Changes
39
+
40
+ - Updated dependencies [a858d8e]
41
+ - @roottale/cms-core@0.63.0
42
+ - @roottale/cms-client@0.63.0
43
+
3
44
  ## 0.62.0
4
45
 
5
46
  ### Minor Changes
package/README.md CHANGED
@@ -170,6 +170,9 @@ export default async function PostPage({
170
170
  apiKey={process.env.ROOTTALE_API_KEY!}
171
171
  slugOrId={post.id}
172
172
  showTableOfContents
173
+ tocPosition="inline"
174
+ theme={null}
175
+ footerPatternPresentation={null}
173
176
  tableOfContentsTitle="목차"
174
177
  breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
175
178
  relatedPostsCount={3}
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-L3I4W5NP.js";
7
7
 
8
8
  // src/exposures.tsx
9
- import { createContext, useCallback as useCallback2, useContext, useEffect as useEffect6, useRef as useRef4, useState as useState6 } from "react";
9
+ import { createContext, useCallback as useCallback3, useContext, useEffect as useEffect7, useRef as useRef5, useState as useState8 } from "react";
10
10
 
11
11
  // src/exposure-decision-client.ts
12
12
  import { useEffect, useState } from "react";
@@ -161,7 +161,7 @@ function useExposureDecision(endpoint, pathname, device, slots, enabled = true)
161
161
  }
162
162
 
163
163
  // src/exposure-group.tsx
164
- import { useEffect as useEffect5, useLayoutEffect, useRef as useRef3, useState as useState5 } from "react";
164
+ import { useEffect as useEffect5, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState6 } from "react";
165
165
 
166
166
  // src/exposure-content.tsx
167
167
  import { useState as useState2 } from "react";
@@ -212,7 +212,7 @@ function contentVariant(campaign) {
212
212
  }
213
213
 
214
214
  // src/exposure-dialog.tsx
215
- import { useEffect as useEffect2, useRef } from "react";
215
+ import { useEffect as useEffect2, useLayoutEffect, useRef, useState as useState3 } from "react";
216
216
  import { createPortal } from "react-dom";
217
217
  import { jsx as jsx2 } from "react/jsx-runtime";
218
218
  var activeDialog = null;
@@ -238,10 +238,38 @@ function exposureCanOpenPopup() {
238
238
  return true;
239
239
  });
240
240
  }
241
- function ExposureDialog({ title, className, children, onClose, device, size, themeElement }) {
241
+ function ExposureDialog({ title, className, children, onClose, onReady, device, size, themeElement, manageFocus = true }) {
242
242
  const rootRef = useRef(null);
243
243
  const dialogRef = useRef(null);
244
244
  const closeRef = useRef(onClose);
245
+ const [ready, setReady] = useState3(false);
246
+ useEffect2(() => {
247
+ if (ready) onReady?.(true);
248
+ }, [ready, onReady]);
249
+ useLayoutEffect(() => {
250
+ const root = rootRef.current;
251
+ if (!root) return;
252
+ const pending = Array.from(root.querySelectorAll("img")).filter((image) => !image.complete);
253
+ if (!pending.length) {
254
+ setReady(true);
255
+ return;
256
+ }
257
+ const remaining = new Set(pending);
258
+ const settle = (event) => {
259
+ remaining.delete(event.currentTarget);
260
+ if (!remaining.size) setReady(true);
261
+ };
262
+ for (const image of pending) {
263
+ image.addEventListener("load", settle);
264
+ image.addEventListener("error", settle);
265
+ }
266
+ return () => {
267
+ for (const image of pending) {
268
+ image.removeEventListener("load", settle);
269
+ image.removeEventListener("error", settle);
270
+ }
271
+ };
272
+ }, []);
245
273
  useEffect2(() => {
246
274
  closeRef.current = onClose;
247
275
  }, [onClose]);
@@ -259,7 +287,7 @@ function ExposureDialog({ title, className, children, onClose, device, size, the
259
287
  useEffect2(() => {
260
288
  const root = rootRef.current;
261
289
  const dialog = dialogRef.current;
262
- if (!root || !dialog) return;
290
+ if (!root || !dialog || !ready) return;
263
291
  activeDialog = dialog;
264
292
  const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
265
293
  const previousOverflow = document.body.style.overflow;
@@ -280,7 +308,7 @@ function ExposureDialog({ title, className, children, onClose, device, size, the
280
308
  const observer = new MutationObserver(inertBackground);
281
309
  observer.observe(document.body, { childList: true });
282
310
  const focusable = () => Array.from(dialog.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex="0"]')).filter((element) => !element.hidden && !element.closest('[hidden], [aria-hidden="true"]'));
283
- (dialog.querySelector("[data-exposure-close]") ?? dialog).focus({ preventScroll: true });
311
+ if (manageFocus) dialog.focus({ preventScroll: true });
284
312
  const handleKey = (event) => {
285
313
  if (event.key === "Escape") {
286
314
  event.preventDefault();
@@ -306,7 +334,7 @@ function ExposureDialog({ title, className, children, onClose, device, size, the
306
334
  if (event.target instanceof Node && !dialog.contains(event.target)) (focusable()[0] ?? dialog).focus({ preventScroll: true });
307
335
  };
308
336
  document.addEventListener("keydown", handleKey, true);
309
- document.addEventListener("focusin", handleFocus);
337
+ if (manageFocus) document.addEventListener("focusin", handleFocus);
310
338
  return () => {
311
339
  observer.disconnect();
312
340
  document.removeEventListener("keydown", handleKey, true);
@@ -315,6 +343,7 @@ function ExposureDialog({ title, className, children, onClose, device, size, the
315
343
  if (activeDialog === dialog) activeDialog = null;
316
344
  document.body.style.overflow = previousOverflow;
317
345
  document.body.style.paddingRight = previousPadding;
346
+ if (!manageFocus) return;
318
347
  if (previous?.isConnected && !previous.inert) previous.focus({ preventScroll: true });
319
348
  else {
320
349
  const fallback = document.querySelector('main, [role="main"]') ?? document.body;
@@ -324,23 +353,23 @@ function ExposureDialog({ title, className, children, onClose, device, size, the
324
353
  if (!hadTabIndex) fallback.removeAttribute("tabindex");
325
354
  }
326
355
  };
327
- }, []);
328
- return createPortal(/* @__PURE__ */ jsx2("div", { ref: rootRef, className: "rt-exposure-backdrop", "data-device": device, children: /* @__PURE__ */ jsx2("section", { ref: dialogRef, className, role: "dialog", "aria-modal": "true", "aria-label": title, tabIndex: -1, "data-size": size, children }) }), document.body);
356
+ }, [ready, manageFocus]);
357
+ return createPortal(/* @__PURE__ */ jsx2("div", { ref: rootRef, className: "rt-exposure-backdrop", "data-device": device, style: { visibility: ready ? void 0 : "hidden" }, children: /* @__PURE__ */ jsx2("section", { ref: dialogRef, className, role: "dialog", "aria-modal": "true", "aria-label": title, tabIndex: -1, "data-size": size, children }) }), document.body);
329
358
  }
330
359
 
331
360
  // src/exposure-carousel.tsx
332
- import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
361
+ import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState4 } from "react";
333
362
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
334
363
  function useExposureCarousel(campaigns, enabled, durationMs, popup) {
335
- const [selection, setSelection] = useState3({ id: campaigns[0]?.id, cycle: 0 });
336
- const [elapsedState, setElapsed] = useState3({ id: campaigns[0]?.id, value: 0 });
337
- const [userPaused, setUserPaused] = useState3(false);
338
- const [motionAllowed, setMotionAllowed] = useState3(false);
339
- const [reducedMotion, setReducedMotion] = useState3(false);
340
- const [hovered, setHovered] = useState3(false);
341
- const [focused, setFocused] = useState3(false);
342
- const [touching, setTouching] = useState3(false);
343
- const [environmentPaused, setEnvironmentPaused] = useState3(false);
364
+ const [selection, setSelection] = useState4({ id: campaigns[0]?.id, cycle: 0 });
365
+ const [elapsedState, setElapsed] = useState4({ id: campaigns[0]?.id, value: 0 });
366
+ const [userPaused, setUserPaused] = useState4(false);
367
+ const [motionAllowed, setMotionAllowed] = useState4(false);
368
+ const [reducedMotion, setReducedMotion] = useState4(false);
369
+ const [hovered, setHovered] = useState4(false);
370
+ const [focused, setFocused] = useState4(false);
371
+ const [touching, setTouching] = useState4(false);
372
+ const [environmentPaused, setEnvironmentPaused] = useState4(false);
344
373
  const elapsedRef = useRef2(0);
345
374
  const touchOrigin = useRef2(null);
346
375
  const index = Math.max(0, campaigns.findIndex((campaign2) => campaign2.id === selection.id));
@@ -489,9 +518,9 @@ function ExposureCarouselControls({ carousel, campaigns }) {
489
518
  }
490
519
 
491
520
  // src/exposure-scroll.ts
492
- import { useEffect as useEffect4, useState as useState4 } from "react";
521
+ import { useEffect as useEffect4, useState as useState5 } from "react";
493
522
  function useMobileExposureScroll(enabled) {
494
- const [hidden, setHidden] = useState4(false);
523
+ const [hidden, setHidden] = useState5(false);
495
524
  useEffect4(() => {
496
525
  if (!enabled) {
497
526
  setHidden(false);
@@ -596,7 +625,7 @@ function nextExposureMidnight(now, timeZone) {
596
625
  function exposureWasHidden(campaign, now, legacyPrefixes = []) {
597
626
  const persistent = readPreferences(preferencesCookie);
598
627
  const preferences = [persistent.get(compactKey(campaign.slotKey, "g")), persistent.get(compactKey(campaign.id, "c")), readPreferences(sessionCookie).get(compactKey(campaign.id, "c"))];
599
- if (preferences.some((preference) => preference?.startsWith("s:") || preference?.startsWith("v:") || preference === "n:1" || preference?.startsWith("u:") && Number(preference.slice(2)) > now)) return true;
628
+ if (preferences.some((preference) => campaign.repeat !== "always" && (preference?.startsWith("s:") || preference?.startsWith("v:") || preference === "n:1") || preference?.startsWith("u:") && Number(preference.slice(2)) > now)) return true;
600
629
  const until = Number(read("local", `rt-exposure-until:${campaign.id}`));
601
630
  if (Number.isFinite(until) && until > now) return true;
602
631
  if (campaign.repeat === "always") return false;
@@ -617,6 +646,7 @@ function rememberExposureView(campaign, now) {
617
646
  if (campaign.delivery && (campaign.repeat === "day" || campaign.repeat === "never")) rememberExposureDismissal(campaign, now);
618
647
  }
619
648
  function rememberExposureDismissal(campaign, now, forToday = false) {
649
+ if (campaign.repeat === "always" && !forToday) return;
620
650
  if (forToday || campaign.repeat === "day") {
621
651
  const until = forToday || campaign.delivery?.dayBoundary === "calendar" ? nextExposureMidnight(now, campaign.delivery?.timeZone ?? "Asia/Seoul") : now + 864e5;
622
652
  writePreferenceKey(compactKey(campaign.id, "c"), `u:${until}`, true, now);
@@ -635,7 +665,7 @@ function rememberExposureGroupDismissal(campaigns, now, forToday = false) {
635
665
  import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
636
666
  function useSlideScrollReset(campaignId) {
637
667
  const ref = useRef3(null);
638
- useLayoutEffect(() => {
668
+ useLayoutEffect2(() => {
639
669
  if (ref.current) ref.current.scrollTop = 0;
640
670
  }, [campaignId]);
641
671
  return ref;
@@ -646,19 +676,20 @@ function exposureGroupSize(campaigns) {
646
676
  return "small";
647
677
  }
648
678
  function ExposureCampaignGroup({ campaigns, runtime, placement, className }) {
649
- const [visible, setVisible] = useState5(false);
650
- const [dismissed, setDismissed] = useState5(false);
651
- const [height, setMeasuredHeight] = useState5(0);
652
- const [inView, setInView] = useState5(() => placement !== "inline" || typeof IntersectionObserver === "undefined");
679
+ const [visible, setVisible] = useState6(Boolean(runtime.preview));
680
+ const [dismissed, setDismissed] = useState6(false);
681
+ const [popupReady, setPopupReady] = useState6(false);
682
+ const [height, setMeasuredHeight] = useState6(0);
683
+ const [inView, setInView] = useState6(() => placement !== "inline" || typeof IntersectionObserver === "undefined");
653
684
  const measuredRef = useRef3(null);
654
685
  const campaignIds = campaigns.map((campaign2) => campaign2.id).join(",");
655
- const [admission, setAdmission] = useState5(() => ({
686
+ const [admission, setAdmission] = useState6(() => ({
656
687
  ids: campaignIds,
657
- allowed: new Map(campaigns.map((campaign2) => [campaign2.id, !exposureWasHidden(campaign2, runtime.now(), runtime.legacyPrefixes)]))
688
+ allowed: new Map(campaigns.map((campaign2) => [campaign2.id, Boolean(runtime.preview) || !exposureWasHidden(campaign2, runtime.now(), runtime.legacyPrefixes)]))
658
689
  }));
659
690
  if (admission.ids !== campaignIds) {
660
691
  const allowed = new Map(admission.allowed);
661
- for (const campaign2 of campaigns) if (!allowed.has(campaign2.id)) allowed.set(campaign2.id, !exposureWasHidden(campaign2, runtime.now(), runtime.legacyPrefixes));
692
+ for (const campaign2 of campaigns) if (!allowed.has(campaign2.id)) allowed.set(campaign2.id, Boolean(runtime.preview) || !exposureWasHidden(campaign2, runtime.now(), runtime.legacyPrefixes));
662
693
  setAdmission({ ids: campaignIds, allowed });
663
694
  }
664
695
  const viewed = useRef3(/* @__PURE__ */ new Set());
@@ -673,12 +704,16 @@ function ExposureCampaignGroup({ campaigns, runtime, placement, className }) {
673
704
  const slotKey = campaigns[0]?.slotKey ?? "";
674
705
  const hiddenOnScroll = useMobileExposureScroll(runtime.device === "mobile" && runtime.hideMobileBannersOnScroll && (placement === "top" || placement === "bottom"));
675
706
  const active = visible && !dismissed && eligible.length > 0;
676
- const carousel = useExposureCarousel(eligible, active && !hiddenOnScroll && inView, runtime.slideDurationMs, placement === "popup");
707
+ const carousel = useExposureCarousel(eligible, active && (placement !== "popup" || popupReady) && !hiddenOnScroll && inView, runtime.slideDurationMs, placement === "popup");
677
708
  const campaign = carousel.campaign;
678
709
  const activeId = campaign?.id;
679
710
  const setHeight = runtime.setHeight;
680
711
  const device = runtime.device;
681
712
  const slideRef = useSlideScrollReset(activeId);
713
+ const reportPreview = runtime.preview?.report;
714
+ useEffect5(() => {
715
+ if (active && (placement !== "popup" || popupReady)) reportPreview?.(true);
716
+ }, [active, placement, popupReady, reportPreview]);
682
717
  useEffect5(() => {
683
718
  const first2 = latest.current.campaigns.find((campaign2) => campaign2.id === firstId);
684
719
  if (visible || dismissed || !first2) return;
@@ -732,10 +767,10 @@ function ExposureCampaignGroup({ campaigns, runtime, placement, className }) {
732
767
  }, [placement, active]);
733
768
  useEffect5(() => {
734
769
  const campaign2 = latest.current.campaigns.find((item) => item.id === activeId);
735
- if (!active || hiddenOnScroll || !inView || !campaign2 || viewed.current.has(campaign2.id)) return;
770
+ if (runtime.preview || !active || hiddenOnScroll || !inView || !campaign2 || viewed.current.has(campaign2.id)) return;
736
771
  viewed.current.add(campaign2.id);
737
772
  rememberExposureView(campaign2, latest.current.runtime.now());
738
- }, [active, hiddenOnScroll, inView, activeId]);
773
+ }, [active, hiddenOnScroll, inView, activeId, runtime.preview]);
739
774
  useEffect5(() => {
740
775
  if (placement !== "top" && placement !== "bottom") return;
741
776
  const element = measuredRef.current;
@@ -759,7 +794,7 @@ function ExposureCampaignGroup({ campaigns, runtime, placement, className }) {
759
794
  }, [active, placement, slotKey, setHeight, hiddenOnScroll, device]);
760
795
  function close(forToday = false) {
761
796
  releaseExposurePopup(owner.current);
762
- rememberExposureGroupDismissal(latest.current.campaigns, latest.current.runtime.now(), forToday);
797
+ if (!latest.current.runtime.preview) rememberExposureGroupDismissal(latest.current.campaigns, latest.current.runtime.now(), forToday);
763
798
  setDismissed(true);
764
799
  }
765
800
  if (!active || !campaign) return null;
@@ -770,7 +805,8 @@ function ExposureCampaignGroup({ campaigns, runtime, placement, className }) {
770
805
  const body = /* @__PURE__ */ jsxs3("div", { className: "rt-exposure-carousel", "aria-roledescription": eligible.length > 1 ? "\uCE90\uB7EC\uC140" : void 0, ...carousel.interactions, children: [
771
806
  /* @__PURE__ */ jsx4("div", { ref: slideRef, className: "rt-exposure-slide", role: "group", "aria-roledescription": eligible.length > 1 ? "\uC2AC\uB77C\uC774\uB4DC" : void 0, "aria-label": `${carousel.index + 1} / ${eligible.length}`, children: /* @__PURE__ */ jsx4(ExposureContent, { campaign, device: runtime.device }, campaign.id) }),
772
807
  closeControls,
773
- /* @__PURE__ */ jsx4(ExposureCarouselControls, { carousel, campaigns: eligible })
808
+ /* @__PURE__ */ jsx4(ExposureCarouselControls, { carousel, campaigns: eligible }),
809
+ placement === "popup" && eligible.length > 1 ? /* @__PURE__ */ jsx4("div", { className: "rt-exposure-sizer", "aria-hidden": "true", inert: true, style: { visibility: "hidden", pointerEvents: "none" }, children: eligible.map((item) => /* @__PURE__ */ jsx4(ExposureContent, { campaign: item, device: runtime.device }, item.id)) }) : null
774
810
  ] });
775
811
  const classes = `rt-exposure rt-exposure--${campaign.kind} rt-exposure--${contentVariant(campaign)}`;
776
812
  if (placement === "popup") return /* @__PURE__ */ jsx4(
@@ -782,6 +818,8 @@ function ExposureCampaignGroup({ campaigns, runtime, placement, className }) {
782
818
  device: runtime.device,
783
819
  size: exposureGroupSize(eligible),
784
820
  themeElement: runtime.themeElement,
821
+ manageFocus: !runtime.preview,
822
+ onReady: setPopupReady,
785
823
  children: body
786
824
  }
787
825
  );
@@ -818,6 +856,97 @@ function RootTaleExposureCarouselPreview({ campaigns, placement = "popup", devic
818
856
  ] }) });
819
857
  }
820
858
 
859
+ // src/exposure-site-preview.ts
860
+ import { useCallback as useCallback2, useEffect as useEffect6, useRef as useRef4, useState as useState7 } from "react";
861
+ var EXPOSURE_PREVIEW_PROTOCOL = "roottale:exposure-preview";
862
+ var defaultOrigins = ["https://admin.roottale.com"];
863
+ function exposureSitePreviewUrl(siteUrl, editorOrigin, session) {
864
+ try {
865
+ const url = new URL(siteUrl);
866
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
867
+ if (url.username || url.password || url.protocol !== "https:" && !(local && url.protocol === "http:")) return null;
868
+ url.pathname = "/";
869
+ url.search = "";
870
+ url.hash = new URLSearchParams({ "rt-exposure-preview": session, origin: editorOrigin }).toString();
871
+ return url.href;
872
+ } catch {
873
+ return null;
874
+ }
875
+ }
876
+ function previewConnection(origins) {
877
+ if (typeof window === "undefined" || window.parent === window) return null;
878
+ const params = new URLSearchParams(window.location.hash.slice(1));
879
+ const session = params.get("rt-exposure-preview");
880
+ const origin = params.get("origin");
881
+ if (!session || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(session) || !origin || ![...defaultOrigins, ...origins].includes(origin)) return null;
882
+ return { session, origin };
883
+ }
884
+ function parseExposurePreviewCampaign(value, slots) {
885
+ try {
886
+ if (JSON.stringify(value).length > 32e3) return null;
887
+ const snapshot = readExposureDecision({ campaigns: [value], evaluatedAt: "2026-01-01T00:00:00Z", validUntil: "2026-01-01T00:01:00Z" }, slots, 0, 0);
888
+ const campaign = snapshot?.decision.campaigns[0];
889
+ if (!campaign?.delivery || campaign.content.title.length > 200 || campaign.content.variant.length > 100) return null;
890
+ for (const field of ["eyebrow", "description", "assetAlt", "mobileAssetAlt", "linkLabel"]) {
891
+ const text = campaign.content[field];
892
+ if (text !== void 0 && (typeof text !== "string" || text.length > 2e3)) return null;
893
+ }
894
+ if (campaign.content.linkTarget !== void 0 && !["self", "blank"].includes(campaign.content.linkTarget)) return null;
895
+ return campaign;
896
+ } catch {
897
+ return null;
898
+ }
899
+ }
900
+ function useExposureSitePreview(slots, origins = defaultOrigins) {
901
+ const [connection] = useState7(() => previewConnection(origins));
902
+ const [draft, setDraft] = useState7(null);
903
+ const revision = useRef4(-1);
904
+ const slotsKey = slots.join(",");
905
+ const reply = useCallback2((type, data = {}) => {
906
+ if (connection) window.parent.postMessage({ type: `${EXPOSURE_PREVIEW_PROTOCOL}:${type}`, session: connection.session, ...data }, connection.origin);
907
+ }, [connection]);
908
+ useEffect6(() => {
909
+ if (!connection) return;
910
+ function receive(event) {
911
+ if (event.source !== window.parent || event.origin !== connection.origin) return;
912
+ const data = event.data;
913
+ if (!data || data.session !== connection.session) return;
914
+ if (data.type === `${EXPOSURE_PREVIEW_PROTOCOL}:connect`) {
915
+ reply("ready");
916
+ return;
917
+ }
918
+ if (data.type !== `${EXPOSURE_PREVIEW_PROTOCOL}:render` || !Number.isSafeInteger(data.revision) || data.revision <= revision.current) return;
919
+ const campaign = parseExposurePreviewCampaign(data.campaign, slotsKey.split(","));
920
+ if (!campaign) {
921
+ reply("error", { revision: data.revision });
922
+ return;
923
+ }
924
+ revision.current = data.revision;
925
+ setDraft({ revision: data.revision, campaign });
926
+ }
927
+ const preventNavigation = (event) => {
928
+ const target = event.target instanceof Element ? event.target : null;
929
+ const previewControl = target?.closest("[data-roottale-exposure-slot], .rt-exposure-backdrop");
930
+ if (event.type === "submit" || !previewControl || target?.closest("a[href]")) {
931
+ event.preventDefault();
932
+ event.stopImmediatePropagation();
933
+ }
934
+ };
935
+ window.addEventListener("message", receive);
936
+ document.addEventListener("click", preventNavigation, true);
937
+ document.addEventListener("submit", preventNavigation, true);
938
+ reply("ready");
939
+ return () => {
940
+ window.removeEventListener("message", receive);
941
+ document.removeEventListener("click", preventNavigation, true);
942
+ document.removeEventListener("submit", preventNavigation, true);
943
+ };
944
+ }, [connection, reply, slotsKey]);
945
+ const draftRevision = draft?.revision ?? -1;
946
+ const report = useCallback2((valid) => reply(valid ? "rendered" : "error", { revision: draftRevision }), [reply, draftRevision]);
947
+ return { active: connection !== null, draft, report };
948
+ }
949
+
821
950
  // src/exposures.tsx
822
951
  import { jsx as jsx5 } from "react/jsx-runtime";
823
952
  var Context = createContext(null);
@@ -831,12 +960,13 @@ function RootTaleExposureProvider({
831
960
  slideDurationMs = 5e3,
832
961
  hideMobileBannersOnScroll = true,
833
962
  legacyDismissalKeyPrefixes = emptyPrefixes,
963
+ previewOrigins,
834
964
  children
835
965
  }) {
836
- const [themeElement, setThemeElement] = useState6(null);
837
- const [device, setDevice] = useState6("desktop");
838
- const [heights, setHeights] = useState6({});
839
- useEffect6(() => {
966
+ const [themeElement, setThemeElement] = useState8(null);
967
+ const [device, setDevice] = useState8("desktop");
968
+ const [heights, setHeights] = useState8({});
969
+ useEffect7(() => {
840
970
  const media = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
841
971
  const update = () => setDevice(media.matches ? "mobile" : "desktop");
842
972
  update();
@@ -846,23 +976,24 @@ function RootTaleExposureProvider({
846
976
  const path = pathname.split(/[?#]/, 1)[0] || "/";
847
977
  const home = isExposureHome(path, exposureHomePaths(homePaths));
848
978
  const routeKey = `${path}:${device}`;
849
- const snapshot = useExposureDecision(endpoint, path, device, slots, home);
850
- const popupVisit = useRef4({ key: routeKey });
851
- useEffect6(() => {
979
+ const preview = useExposureSitePreview(slots, previewOrigins);
980
+ const snapshot = useExposureDecision(endpoint, path, device, slots, home && !preview.active);
981
+ const popupVisit = useRef5({ key: routeKey });
982
+ useEffect7(() => {
852
983
  popupVisit.current = { key: routeKey };
853
984
  }, [routeKey]);
854
- const now = useCallback2(() => snapshot ? exposureServerNow(snapshot) : Date.now(), [snapshot]);
855
- const claimPopup = useCallback2((slot) => {
985
+ const now = useCallback3(() => snapshot ? exposureServerNow(snapshot) : Date.now(), [snapshot]);
986
+ const claimPopup = useCallback3((slot) => {
856
987
  const claimed = popupVisit.current.slot;
857
988
  if (claimed && claimed !== slot) return false;
858
989
  popupVisit.current.slot = slot;
859
990
  return true;
860
991
  }, []);
861
- const setHeight = useCallback2((key, placement, height) => {
992
+ const setHeight = useCallback3((key, placement, height) => {
862
993
  setHeights((previous) => previous[key]?.height === height && previous[key]?.placement === placement ? previous : { ...previous, [key]: { placement, height } });
863
994
  }, []);
864
995
  const value = {
865
- campaigns: liveCampaigns(snapshot),
996
+ campaigns: preview.active ? preview.draft ? [preview.draft.campaign] : [] : liveCampaigns(snapshot),
866
997
  device,
867
998
  routeKey,
868
999
  home,
@@ -872,7 +1003,8 @@ function RootTaleExposureProvider({
872
1003
  claimPopup,
873
1004
  setHeight,
874
1005
  legacyPrefixes: legacyDismissalKeyPrefixes,
875
- themeElement
1006
+ themeElement,
1007
+ preview: preview.active ? { revision: preview.draft?.revision ?? 0, report: preview.report } : void 0
876
1008
  };
877
1009
  const top = Math.max(0, ...Object.values(heights).filter((item) => item.placement === "top").map((item) => item.height));
878
1010
  const bottom = Math.max(0, ...Object.values(heights).filter((item) => item.placement === "bottom").map((item) => item.height));
@@ -881,14 +1013,20 @@ function RootTaleExposureProvider({
881
1013
  }
882
1014
  function RootTaleExposureSlot({ slotKey, placement, allowedVariants, fallback = null, className = "" }) {
883
1015
  const runtime = useContext(Context);
1016
+ const previewCampaign = runtime?.preview ? runtime.campaigns[0] : void 0;
1017
+ const reportPreview = runtime?.preview?.report;
1018
+ const validPreview = previewCampaign?.slotKey === slotKey && previewCampaign.kind === (placement === "popup" ? "popup" : "banner") && allowedVariants.includes(previewCampaign.content.variant);
1019
+ useEffect7(() => {
1020
+ if (previewCampaign?.slotKey === slotKey && !validPreview) reportPreview?.(false);
1021
+ }, [previewCampaign, slotKey, validPreview, reportPreview]);
884
1022
  if (runtime && !runtime.home) return fallback;
885
1023
  const campaigns = runtime?.campaigns.filter((item) => item.slotKey === slotKey && item.kind === (placement === "popup" ? "popup" : "banner") && allowedVariants.includes(item.content.variant)).slice(0, 20);
886
1024
  if (!runtime || !campaigns?.length) return fallback;
887
- return /* @__PURE__ */ jsx5(ExposureCampaignGroup, { campaigns, runtime, placement, className }, `${runtime.routeKey}:${slotKey}`);
1025
+ return /* @__PURE__ */ jsx5(ExposureCampaignGroup, { campaigns, runtime, placement, className }, `${runtime.routeKey}:${slotKey}:${runtime.preview?.revision ?? "live"}`);
888
1026
  }
889
1027
  function RootTaleLegacyExposure({ campaign, allowedVariants }) {
890
- const [device, setDevice] = useState6("desktop");
891
- useEffect6(() => {
1028
+ const [device, setDevice] = useState8("desktop");
1029
+ useEffect7(() => {
892
1030
  const media = window.matchMedia("(max-width: 767px)");
893
1031
  const update = () => setDevice(media.matches ? "mobile" : "desktop");
894
1032
  update();
@@ -915,8 +1053,11 @@ function RootTaleLegacyExposure({ campaign, allowedVariants }) {
915
1053
  export {
916
1054
  RootTaleExposurePreview,
917
1055
  RootTaleExposureCarouselPreview,
1056
+ EXPOSURE_PREVIEW_PROTOCOL,
1057
+ exposureSitePreviewUrl,
1058
+ parseExposurePreviewCampaign,
918
1059
  RootTaleExposureProvider,
919
1060
  RootTaleExposureSlot,
920
1061
  RootTaleLegacyExposure
921
1062
  };
922
- //# sourceMappingURL=chunk-SEPUIKVM.js.map
1063
+ //# sourceMappingURL=chunk-LUL73SKP.js.map