// DeviceDuo: laptop + phone frames showing a live site that auto-scrolls. // The site loads automatically when the frames scroll into view (IntersectionObserver, // so the page still opens fast), then the iframe is panned up/down inside the clipped // screen to simulate continuous live scrolling — cross-origin means we cannot script // scroll inside the site itself, so we translate the whole rendered page instead. const NS_LIVE = window.AnsysDigitalDesignSystem_4543d7; if (!document.getElementById("ls-scroll-kf")) { const st = document.createElement("style"); st.id = "ls-scroll-kf"; st.textContent = "@keyframes lsScroll{0%,6%{transform:translateY(0)}50%,56%{transform:translateY(var(--ls-shift))}100%{transform:translateY(0)}}" + "@keyframes lsPan{0%,6%{object-position:center 0%}50%,56%{object-position:center 100%}100%{object-position:center 0%}}" + "@media (prefers-reduced-motion:reduce){.ls-shot{animation:none!important}}" + "@keyframes spin{to{transform:rotate(360deg)}}" + "@media (prefers-reduced-motion:reduce){.ls-pan{animation:none!important}}"; document.head.appendChild(st); } // Render order: `shot` screenshot (best) > live iframe when `liveFrame` is explicitly // enabled for a store confirmed to allow framing > branded "Visit store" panel. // Most Shopify storefronts send X-Frame-Options / CSP frame-ancestors, and a refused // frame still fires `load`, so framing cannot be feature-detected — hence the opt-in. function DeviceDuo({ url, name, shot, liveFrame = false }) { const [live, setLive] = React.useState(false); const [shotFailed, setShotFailed] = React.useState(false); // Client storefronts refuse to be framed (X-Frame-Options / CSP frame-ancestors) and a // refused frame still fires load, so framing can't be detected. Real screenshots are // rendered instead: a local `shot` file when supplied, otherwise WordPress mShots. const [src2, setSrc2] = React.useState(false); const bare = url.replace(/^https?:\/\//, "").replace(/\/$/, ""); const autoShot = (w, h) => src2 ? "https://image.thum.io/get/width/" + w + (h ? "/crop/" + h : "") + "/noanimate/https://" + bare : "https://s.wordpress.com/mshots/v1/" + encodeURIComponent(url) + "?w=" + w + (h ? "&h=" + h : ""); const [lapScale, setLapScale] = React.useState(0.34); const [phScale, setPhScale] = React.useState(0.34); const rootRef = React.useRef(null); const lapRef = React.useRef(null); const phRef = React.useRef(null); // Load the live site only once the frames are on screen. React.useEffect(() => { if (!rootRef.current || live) return; const io = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) { setLive(true); io.disconnect(); } }, { rootMargin: "200px" }); io.observe(rootRef.current); return () => io.disconnect(); }, [live]); React.useEffect(() => { let raf = 0; const measure = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { if (lapRef.current) { const n = lapRef.current.clientWidth / 1280; setLapScale((p) => Math.abs(n - p) < 0.002 ? p : n); } if (phRef.current) { const n = phRef.current.clientWidth / 390; setPhScale((p) => Math.abs(n - p) < 0.002 ? p : n); } }); }; measure(); window.addEventListener("resize", measure); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", measure); }; }, [live]); const host = url.replace(/^https?:\/\//, "").replace(/\/$/, ""); const Loading = ({ small }) => (