/** * A real page in an iframe, laid out at a device width and scaled down to fit: * the booking page with an unsaved look (Design), a booking-site section (Add * to site), the floating button on this site's home page. * * No preview sends a referrer. A page from another origin (the booking site) * is sandboxed to what a booking page needs: scripts, its own origin, forms * and popups. This site's own home page is not sandboxed — a same-origin frame * with scripts can lift its sandbox anyway — and is made inert instead (no * pointer events, out of the tab order) unless `interactive`. */ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { SegmentedFilter, cn } from '../ds'; import { safeUrl } from '../lib/url'; const CROSS_ORIGIN_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'; /** `source` on every message the booking site's frame posts (frame/embedBridge.ts). */ const FRAME_SOURCE = 'razorbooking-embed'; export type PreviewDevice = 'desktop' | 'phone'; const SIZES: Record< PreviewDevice, { width: number; height: number } > = { desktop: { width: 1280, height: 800 }, phone: { width: 390, height: 780 }, }; interface DeviceToggleProps { value: PreviewDevice; onChange: ( next: PreviewDevice ) => void; } export function DeviceToggle( { value, onChange }: DeviceToggleProps ) { return ( onChange( next as PreviewDevice ) } options={ [ { label: 'Desktop', value: 'desktop' }, { label: 'Phone', value: 'phone' }, ] } /> ); } interface ScaledFrameProps { src: string; title: string; device: PreviewDevice; /** The tallest the scaled frame may be on screen. */ maxHeight?: number; /** Let the preview be clicked through. */ interactive?: boolean; /** * A booking-site page: pass it `rb_origin` so it reports `rb:empty`, and * show `empty` over the frame when it does. */ detectEmpty?: boolean; /** What to say when the page reports it has nothing to show. */ empty?: ReactNode; /** The framed page's own corner radius at full size (CSS px); scaled with it. */ radius?: string; } export function ScaledFrame( { src, title, device, maxHeight = 420, interactive = false, detectEmpty = false, empty, radius, }: ScaledFrameProps ) { const { width, height } = SIZES[ device ]; const [ scale, setScale ] = useState( 0.3 ); const [ loading, setLoading ] = useState( true ); const [ isEmpty, setIsEmpty ] = useState( false ); const box = useRef< HTMLDivElement >( null ); const frame = useRef< HTMLIFrameElement >( null ); const parsed = useMemo( () => safeUrl( src ), [ src ] ); const invalid = !! src && ! parsed; const framed = useMemo( () => { if ( ! parsed ) return ''; const url = new URL( parsed.toString() ); if ( detectEmpty ) url.searchParams.set( 'rb_origin', window.location.origin ); return url.toString(); }, [ parsed, detectEmpty ] ); const origin = parsed?.origin ?? null; const crossOrigin = !! origin && origin !== window.location.origin; useEffect( () => { setLoading( true ); setIsEmpty( false ); }, [ framed ] ); useEffect( () => { if ( ! origin ) return; const onMessage = ( event: MessageEvent ) => { const data = event.data as { source?: string; type?: string } | null; if ( event.origin !== origin || event.source !== frame.current?.contentWindow || data?.source !== FRAME_SOURCE ) return; if ( data.type === 'rb:empty' && detectEmpty ) setIsEmpty( true ); }; window.addEventListener( 'message', onMessage ); return () => window.removeEventListener( 'message', onMessage ); }, [ origin, detectEmpty ] ); useEffect( () => { const el = box.current; if ( ! el ) return; const fit = () => setScale( Math.min( el.clientWidth / width, maxHeight / height, 1 ) ); fit(); const observer = new ResizeObserver( fit ); observer.observe( el ); return () => observer.disconnect(); }, [ width, height, maxHeight ] ); return (
{ framed ? (