import React, { useEffect } from 'react'; import { openHttpsInNewBrowserWindow, openQortalLinkInVisitorContext, normalizeQortalWebsiteHref } from './lib/openQortal'; import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AppErrorBoundary } from './ui/AppErrorBoundary'; import { WebBuilderCanvas } from './ui/WebBuilderCanvas'; import { SiteViewContext } from './context/SiteViewContext'; import { useBuilderStore } from './store/builderStore'; import { mergeSite } from './lib/mergeSite'; import { buildVisitorRetainSnapshots } from './lib/visitorRetainSnapshots'; import { isQortalRuntime } from './lib/qortalEnv'; import { primeAndWarmQAppEmbedsFromSite } from './lib/primeQAppEmbeds'; import { enableVisitorPublishedSite } from './lib/visitorPublishedSite'; import { loadPublishedSiteRaw } from './lib/publishedSiteConfig'; import { readVisitorThemeForPublisher } from './lib/visitorThemeStorage'; import { deliverDiscoverCaptureToParent, isDiscoverCaptureRuntime, registerDiscoverCaptureConfigBridge, } from './lib/discoverCaptureProtocol'; import { runDiscoverCaptureInDocument, waitForCaptureBlocksInDocument, } from './lib/discoverPreviewCapture'; import { VisitorAuthBar } from './ui/VisitorAuthBar'; import { VisitorSoftEditBar } from './ui/VisitorSoftEditBar'; import { PublishWebsiteModal } from './ui/PublishWebsiteModal'; import { VisitorGroupAccessGate } from './ui/VisitorGroupAccessGate'; import { RteMediaLinkOverlay } from './ui/RteMediaLinkOverlay'; import { useQortalApiBridge } from './hooks/useQortalApiBridge'; import { navigateToSameSiteSlug as sameSiteNavigate, parseSameSiteSlug as sameSiteParse, } from './lib/sameSiteNav'; import { scrollBlockIntoView, blockSectionDomId } from './lib/visitorPeerNavigate'; import { QORTAL_WEB_EMBED_ARTICLE_OPEN, QORTAL_WEB_EMBED_AUTH_SESSION, QORTAL_WEB_EMBED_BLOCK_FOCUS, QORTAL_WEB_EMBED_READY, QORTAL_WEB_EMBED_SCROLL, QORTAL_WEB_EMBED_THEME, type QortalWebEmbedAuthMessage, } from './lib/qortalWebEmbed'; import type { BuilderTheme } from './types/site'; import '../style.css'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false }, }, }); /** * Extracts the page slug from the published WEBSITE URL. Two routing modes are supported on the * visitor side: * * 1. **Hash routing** (preferred for SPA navigation between pages — no Qortal Core round-trip * and no need for per-slug `index.html` in the published archive): * qortal://WEBSITE//# * Internal nav clicks (Header → "Page" or same-publisher "Qortal site" buttons) flip * `location.hash` and the SPA re-renders the matching page WITHOUT reloading the document. * * 2. **Pathname routing** (deep-link friendly — used when the visitor lands on the URL directly, * e.g. shared bookmarks). Requires `/index.html` to exist in the published bundle so * Qortal Core can serve a static file at that path. We bundle these copies in * `buildWebsiteFileBundle` so the deep-link case works. * * Hash takes precedence over pathname — if both are set, the hash wins (clean URL after a copy / * paste during in-app nav). The pinning script in `site.html` keeps the iframe URL canonical and * strips duplicated `` segments that the Hub used to inject. */ function readPageSlugFromUrl(): string { if (typeof location === 'undefined') return ''; try { const rawHash = (location.hash || '').replace(/^#+/, '').trim(); if (rawHash) { const head = rawHash.split('/')[0] || ''; return decodeURIComponent(head).toLowerCase(); } const m = /^\/render\/(?:WEBSITE|APP)\/[^/]+\/(.*)$/i.exec(location.pathname || ''); if (!m) return ''; const tail = (m[1] || '').replace(/^\/+/, '').replace(/\/+$/, ''); if (!tail) return ''; /** First segment of the remaining path is the slug. */ const slug = tail.split('/')[0] || ''; return decodeURIComponent(slug).toLowerCase(); } catch { return ''; } } /** * Cached merged site config (parsed once at startup). Used by the hashchange / popstate listeners * to swap pages without re-parsing the JSON / re-running mergeSite each navigation. */ let cachedMergedSite: ReturnType | null = null; /** * Same-site nav: thin wrappers that pair the URL update from `lib/sameSiteNav.ts` with the * embed-driven `setStoreToSlug` swap so a single click both navigates the URL and re-renders * the SPA. Exported here so the click router can use them via `parseSameSiteSlug` / * `navigateToSameSiteSlug` aliases imported above. */ function parseSameSiteSlug(href: string): string | null { return sameSiteParse(href); } function navigateToSameSiteSlug(slug: string) { if (sameSiteNavigate(slug)) { /** `history.pushState` does not fire `hashchange` automatically when the hash changes; * `sameSiteNavigate` dispatches the event so our listener picks it up. We also trigger an * immediate render so the "click → page renders" feedback is instant rather than waiting * for the synthetic event to roundtrip through the listener. */ setStoreToSlug(slug); } } function setStoreToSlug(slug: string) { if (!cachedMergedSite) return; const merged = cachedMergedSite; /** Rebuilt `site` from cached merge each navigation — keep the visitor's theme from the store. */ const visitorTheme = useBuilderStore.getState().site.theme; const retain = useBuilderStore.getState().visitorRetainSnapshots; let site: ReturnType; if (retain && retain.length > 1) { const homeSnap = retain.find((r) => r.pageId === '__home__'); if (!homeSnap) { site = { ...merged, theme: visitorTheme }; } else if (slug) { const match = (merged.pages || []).find( (p) => p.id !== '__home__' && p.slug.toLowerCase() === slug, ); if (match) { const snap = retain.find((r) => r.pageId === match.id); if (snap) { site = { ...merged, blocks: snap.blocks, activePageId: match.id, pages: merged.pages }; if (match.title) { site = { ...site, docTitle: match.title }; } } else { site = { ...merged, blocks: homeSnap.blocks, activePageId: null, pages: merged.pages, docTitle: merged.docTitle, }; } } else { site = { ...merged, blocks: homeSnap.blocks, activePageId: null, pages: merged.pages, docTitle: merged.docTitle, }; } } else { site = { ...merged, blocks: homeSnap.blocks, activePageId: null, pages: merged.pages, docTitle: merged.docTitle, }; } site = { ...site, theme: visitorTheme }; } else { let s = merged; if (slug) { const match = (merged.pages || []).find( (p) => p.id !== '__home__' && p.slug.toLowerCase() === slug, ); if (match) { s = { ...merged, blocks: match.blocks, activePageId: match.id }; if (match.title) { s = { ...s, docTitle: match.title }; } } } else { /** Hash cleared / pathname home — canonical home snapshot + predictable `activePageId` for Q-App persist keys. */ s = { ...merged, blocks: merged.blocks, activePageId: null }; } site = { ...s, theme: visitorTheme }; } useBuilderStore.setState({ site, selectedBlockId: null }); /** Reflect the page title on the browser tab so the Hub displays the active page. */ if (typeof document !== 'undefined') { const t = (site.docTitle || '').trim(); if (t) document.title = t; } /** Maintain a `` whose href matches the current canonical Qortal URL. * Hubs that prefer canonical metadata over the raw iframe URL (some builds do, some don't) * can copy that link instead of the address-bar memory of the tab's entry URL. Cheap, no-op * for hubs that ignore it. */ updateCanonicalLink(site.publisherName, slug); /** Tell the Hub what resource is now displayed so its address bar / "Copy link" tracks our * WEBSITE rather than any embedded Q-App (e.g. an embedded Q-Blog that fires its own * `QDN_RESOURCE_DISPLAYED` — without this the embed wins the race and Hub's copy-link * returns the embed's URL). */ notifyHubResourceDisplayed(site.publisherName, slug); /** Visitors expect to land at the top of the next page (not stay scrolled on the previous one's * footer). Skip when no scrollport exists yet (initial mount). */ try { const sp = document.querySelector('.qwb-visitor'); if (sp instanceof HTMLElement) sp.scrollTo({ top: 0, behavior: 'auto' }); } catch { /* */ } } function updateCanonicalLink(publisherName: string | undefined, slug: string) { if (typeof document === 'undefined') return; const name = String(publisherName || '').trim(); if (!name) return; const base = 'qortal://WEBSITE/' + encodeURIComponent(name); const href = slug ? base + '/' + encodeURIComponent(slug) : base; let link = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null; if (!link) { link = document.createElement('link'); link.setAttribute('rel', 'canonical'); document.head.appendChild(link); } if (link.href !== href) { link.setAttribute('href', href); } } /** * Tell the Hub which WEBSITE resource (and which sub-path) the visitor is currently looking at. * This is the official Qortal-Hub mechanism for "set the address bar / copy-link target" — see * `QDN_RESOURCE_DISPLAYED` in the Q-Apps doc. We need to fire this whenever the SPA swaps pages * AND on initial mount so an embedded Q-App child (e.g. a Q-Blog iframe inside the WEBSITE) * cannot win the race and leave Hub's address bar pointing at the embed instead of at our * WEBSITE — which is exactly the symptom that produced the "Copy link returns the embed's path * (//)" report. * * The published-site bridge (`site.html`) ALSO blocks `QDN_RESOURCE_DISPLAYED` from the child * iframe; this call here is the "tell Hub the right value" half of the same fix. */ function notifyHubResourceDisplayed(publisherName: string | undefined, slug: string) { if (typeof window === 'undefined') return; const name = String(publisherName || '').trim(); if (!name) return; const w = window as Window & { qortalRequest?: (req: Record) => Promise; }; if (typeof w.qortalRequest !== 'function') return; try { w.qortalRequest({ action: 'QDN_RESOURCE_DISPLAYED', service: 'WEBSITE', name, identifier: 'default', ...(slug ? { path: '/' + slug.replace(/^\/+/, '') } : {}), }).catch(() => { /* Non-fatal — older Hub builds may not expose this action; the canonical link + URL pin * still keep things internally consistent. */ }); } catch { /* */ } } function applyLoadedSite(raw: unknown) { const merged = mergeSite(raw); cachedMergedSite = merged; /** * Multi-page: the published bundle contains the home page (`merged.blocks`) plus all * sub-pages in `merged.pages[]`. The visitor's URL determines which one renders. We mutate * `site.blocks` to point at the matching page's blocks before storing in zustand so the rest * of the visitor app code (which reads `state.site.blocks`) doesn't need to know about pages. */ const slug = readPageSlugFromUrl(); const visitorRetainSnapshots = buildVisitorRetainSnapshots(merged); let site: ReturnType; if (visitorRetainSnapshots && visitorRetainSnapshots.length > 1) { const homeSnap = visitorRetainSnapshots.find((r) => r.pageId === '__home__')!; if (slug) { const match = (merged.pages || []).find( (p) => p.id !== '__home__' && p.slug.toLowerCase() === slug, ); if (match) { const snap = visitorRetainSnapshots.find((r) => r.pageId === match.id); if (snap) { site = { ...merged, blocks: snap.blocks, activePageId: match.id, pages: merged.pages }; if (match.title) { site = { ...site, docTitle: match.title }; } } else { site = { ...merged, blocks: homeSnap.blocks, activePageId: null, pages: merged.pages, docTitle: merged.docTitle, }; } } else { site = { ...merged, blocks: homeSnap.blocks, activePageId: null, pages: merged.pages, docTitle: merged.docTitle, }; } } else { site = { ...merged, blocks: homeSnap.blocks, activePageId: null, pages: merged.pages, docTitle: merged.docTitle, }; } } else { let s = merged; if (slug) { const match = (merged.pages || []).find( (p) => p.id !== '__home__' && p.slug.toLowerCase() === slug, ); if (match) { s = { ...merged, blocks: match.blocks, activePageId: match.id }; if (match.title) { s = { ...s, docTitle: match.title }; } } } else { s = { ...merged, blocks: merged.blocks, activePageId: null }; } site = s; } const pub = String(merged.publisherName || '').trim(); const persistedTheme = pub ? readVisitorThemeForPublisher(pub) : null; if (persistedTheme) { site = { ...site, theme: persistedTheme }; } useBuilderStore.setState({ site, visitorRetainSnapshots, selectedBlockId: null, inQortal: isQortalRuntime(), }); /** Set the initial canonical link before React mounts so Hubs that read it on tab-open get * the right value from the very first render. */ updateCanonicalLink(site.publisherName, slug); /** Initial QDN_RESOURCE_DISPLAYED notification — fired with a small delay so Hub's bridge * (`qortalRequest`) is definitely installed first. We re-fire on every SPA navigation via * `setStoreToSlug`, but the FIRST one ensures Hub's address bar shows our WEBSITE before any * child Q-App embed's own QDN_RESOURCE_DISPLAYED race-condition kicks in. */ if (!isDiscoverCaptureRuntime()) { window.setTimeout(() => notifyHubResourceDisplayed(site.publisherName, slug), 0); } if (!isDiscoverCaptureRuntime() && isQortalRuntime()) { void primeAndWarmQAppEmbedsFromSite(site).catch(() => { /* non-fatal */ }); } useBuilderStore.getState().setToastHandler((m, k) => { try { if (k === 'err') { console.error('[visitor]', m); } else if (k === 'warn') { console.warn('[visitor]', m); } else { console.log('[visitor]', m); } } catch { /* */ } }); } const discoverCaptureConfigPromise = registerDiscoverCaptureConfigBridge(); /** StrictMode remount must not cancel an in-flight capture run. */ let discoverCaptureRunnerStarted = false; async function bootVisitorSite(): Promise { enableVisitorPublishedSite(); const capturePromise = discoverCaptureConfigPromise ?? registerDiscoverCaptureConfigBridge(); if (capturePromise) { const raw = await capturePromise; if (!raw) return; try { applyLoadedSite(raw); } catch (e) { try { console.error('published site config error', e); } catch { /* */ } } return; } const raw = await loadPublishedSiteRaw(); if (!raw) return; try { applyLoadedSite(raw); } catch (e) { try { console.error('published site config error', e); } catch { /* */ } } } function VisitorBootError() { return (

This website could not load its configuration. Republish from Qortal Web Builder.

); } function DiscoverCaptureRunner() { useEffect(() => { if (!isDiscoverCaptureRuntime() || discoverCaptureRunnerStarted) return; discoverCaptureRunnerStarted = true; void (async () => { try { await waitForCaptureBlocksInDocument(document, 1, 24_000); const file = await runDiscoverCaptureInDocument(document); if (!file || file.size <= 512) { deliverDiscoverCaptureToParent({ ok: false, error: 'empty capture' }); return; } const png = new Uint8Array(await file.arrayBuffer()); deliverDiscoverCaptureToParent({ ok: true, png }); } catch (e) { deliverDiscoverCaptureToParent({ ok: false, error: e instanceof Error ? e.message : String(e), }); } })(); }, []); return null; } function applyEmbedBlockFocus(blockDomId: string, articleId?: string) { const secId = blockSectionDomId(blockDomId); const artId = String(articleId || '').trim(); try { document.documentElement.setAttribute('data-qwb-qw-block-focus', '1'); if (artId) document.documentElement.setAttribute('data-qwb-qw-article-id', artId); else document.documentElement.removeAttribute('data-qwb-qw-article-id'); } catch { /* */ } let css = ` [data-qwb-qw-block-focus="1"] .qwb-visitor-auth, [data-qwb-qw-block-focus="1"] .qwb-visitor-soft-edit { display:none!important; } [data-qwb-qw-block-focus="1"] [data-qwb-visitor] .qwb-block--public { display:none!important; } [data-qwb-qw-block-focus="1"] [data-qwb-visitor] #${secId} { display:block!important; } [data-qwb-qw-block-focus="1"] [data-qwb-visitor] .qwb-main { padding-top:0!important; } `.trim(); if (artId) { const cardId = 'qwb-article-' + artId.replace(/[^a-zA-Z0-9_-]/g, '-'); css += ` [data-qwb-qw-block-focus="1"] #${secId} .qwb-articles__card:not(#${cardId}) { display:none!important; } [data-qwb-qw-block-focus="1"] #${secId} .qwb-articles__pager { display:none!important; } [data-qwb-qw-block-focus="1"] #${secId} .qwb-articles__adminbar { display:none!important; } `.trim(); } try { const id = 'qwb-qortal-web-block-focus'; let el = document.getElementById(id); if (!el) { el = document.createElement('style'); el.id = id; (document.head || document.documentElement).appendChild(el); } el.textContent = css; } catch { /* */ } scrollBlockIntoView(blockDomId); } function applyEmbedArticleOpen(blockDomId: string, articleId: string) { const block = String(blockDomId || '').trim(); const art = String(articleId || '').trim(); if (!block || !art) return; applyEmbedBlockFocus(block, art); useBuilderStore.getState().setVisitorPeerHighlight({ blockDomId: block, kind: 'article', resourceId: art, articleId: art, }); } function postEmbedReadyToParent() { try { if (!(window as Window & { __QWB_QORTAL_WEB_EMBED__?: boolean }).__QWB_QORTAL_WEB_EMBED__) return; window.parent?.postMessage({ type: QORTAL_WEB_EMBED_READY }, '*'); } catch { /* cross-origin parent */ } } function applyEmbedShellTheme(siteTheme: BuilderTheme) { useBuilderStore.setState((s) => ({ site: { ...s.site, theme: siteTheme }, })); try { document.querySelectorAll('[data-site-theme]').forEach((el) => { el.setAttribute('data-site-theme', siteTheme); }); } catch { /* */ } } function VisitorRoot() { const captureMode = isDiscoverCaptureRuntime(); const docTitle = useBuilderStore((s) => s.site.docTitle); const theme = useBuilderStore((s) => s.site.theme); const pageWidth = useBuilderStore((s) => s.site.pageWidth); const resolveOwner = useBuilderStore((s) => s.resolveOwner); const toast = useBuilderStore((s) => s.toast); useQortalApiBridge(); useEffect(() => { const onMsg = (ev: MessageEvent) => { const d = ev.data as { type?: string; blockId?: string; blockDomId?: string; siteTheme?: string } | null; if (!d) return; if (d.type === QORTAL_WEB_EMBED_SCROLL) { const blockId = String(d.blockId || '').trim(); if (blockId) scrollBlockIntoView(blockId); return; } if (d.type === QORTAL_WEB_EMBED_BLOCK_FOCUS) { const blockDomId = String(d.blockDomId || d.blockId || '').trim(); const articleId = typeof d.articleId === 'string' ? d.articleId.trim() : ''; if (blockDomId) applyEmbedBlockFocus(blockDomId, articleId || undefined); return; } if (d.type === QORTAL_WEB_EMBED_ARTICLE_OPEN) { const blockDomId = String(d.blockDomId || d.blockId || '').trim(); const articleId = String(d.articleId || '').trim(); if (blockDomId && articleId) applyEmbedArticleOpen(blockDomId, articleId); return; } if (d.type === QORTAL_WEB_EMBED_THEME) { const t = d.siteTheme === 'paper' || d.siteTheme === 'slate' ? d.siteTheme : null; if (t) applyEmbedShellTheme(t); } }; window.addEventListener('message', onMsg); return () => window.removeEventListener('message', onMsg); }, []); /** Qortal Web Q-App embed: inherit masthead authentication from parent Qortal Web. */ useEffect(() => { try { (window as Window & { __QWB_QORTAL_WEB_EMBED__?: boolean }).__QWB_QORTAL_WEB_EMBED__ = true; } catch { /* */ } }, []); useEffect(() => { const onMsg = (ev: MessageEvent) => { const d = ev.data as QortalWebEmbedAuthMessage | null; if (!d || d.type !== QORTAL_WEB_EMBED_AUTH_SESSION) return; const u = d.user; const store = useBuilderStore.getState(); if (!u?.address) { store.setUserAccount(null); store.setSessionTag(Date.now()); return; } store.setUserAccount({ address: String(u.address).trim(), publicKey: typeof u.publicKey === 'string' ? u.publicKey : undefined, primaryName: typeof u.primaryName === 'string' ? u.primaryName : undefined, }); store.setSessionTag(Date.now()); if (!store.inQortal) { useBuilderStore.setState({ inQortal: true }); } void store.resolveOwner().finally(postEmbedReadyToParent); return; }; window.addEventListener('message', onMsg); return () => window.removeEventListener('message', onMsg); }, []); useEffect(() => { const t = (docTitle || '').trim(); if (t) { document.title = t; } }, [docTitle]); useEffect(() => { if (captureMode) return; void resolveOwner().finally(postEmbedReadyToParent); }, [captureMode, resolveOwner]); /** Lock page scroll to the visitor root so `position:sticky` on the site header uses this scrollport (not the window). */ useEffect(() => { document.documentElement.classList.add('qwb-pub'); return () => document.documentElement.classList.remove('qwb-pub'); }, []); /** * SPA-style internal navigation: when the URL hash or path changes, swap to the matching * sub-page without reloading the document. Triggered by clicks on Header "Page" / same-site * "Qortal site" buttons (which do `location.hash = ''`) and by browser back / forward. */ useEffect(() => { const onUrlChange = () => { const slug = readPageSlugFromUrl(); setStoreToSlug(slug); }; window.addEventListener('hashchange', onUrlChange); window.addEventListener('popstate', onUrlChange); return () => { window.removeEventListener('hashchange', onUrlChange); window.removeEventListener('popstate', onUrlChange); }; }, []); /** Rich text and other raw HTML may render `` without React onClick — route qortal and same-tab web URLs like block links. */ useEffect(() => { const onClick = (e: MouseEvent) => { if (e.defaultPrevented) return; const el = (e.target as Element | null)?.closest?.('a[href]'); if (!el || !document.querySelector('[data-qwb-visitor]')?.contains(el)) return; const href = el.getAttribute('href'); if (!href) return; const h = href.trim(); if (/^mailto:/i.test(h) || /^tel:/i.test(h)) return; if (/^qortal:/i.test(h)) { const qHref = /^qortal:\/\/WEBSITE\//i.test(h) ? normalizeQortalWebsiteHref(h) : h; /** * If the qortal:// URL points at THIS website (same registered name), keep the user on * the page and switch via hash routing instead of round-tripping through the Hub. */ const sameSiteSlug = parseSameSiteSlug(qHref); if (sameSiteSlug !== null) { e.preventDefault(); e.stopPropagation(); navigateToSameSiteSlug(sameSiteSlug); return; } e.preventDefault(); e.stopPropagation(); openQortalLinkInVisitorContext(qHref, toast); return; } if (/^https?:\/\//i.test(h)) { e.preventDefault(); e.stopPropagation(); openHttpsInNewBrowserWindow(h); } }; document.addEventListener('click', onClick, true); return () => document.removeEventListener('click', onClick, true); }, [toast]); return ( {captureMode ? (
) : (
)}
); } const rootEl = document.getElementById('root'); if (!rootEl) { throw new Error('Missing #root'); } void bootVisitorSite().then(() => { const app = ( {cachedMergedSite ? : } ); ReactDOM.createRoot(rootEl).render( isDiscoverCaptureRuntime() ? app : {app}, ); });