/* === app.jsx ========================================================
Top-level: topbar + builder panel + canvas, plus preview mode,
edit mode and share modal.
===================================================================== */
const { DEFAULT_STATE, AE, DOCOL, PERSONAS } = window.STUDIO_DATA;
/* ---------- Top bar ---------- */
function StudioTopbar({ state, setState, demo, onBackToLibrary, onRenameDemo }) {
const [editingName, setEditingName] = React.useState(false);
const [draftName, setDraftName] = React.useState('');
const startEditName = () => {
setDraftName(demo?.name || '');
setEditingName(true);
};
const commitName = () => {
if (draftName.trim() && onRenameDemo) onRenameDemo(draftName.trim());
setEditingName(false);
};
const screens = [
{ id: 'chat', label: 'Chat', icon: 'message-circle' },
{ id: 'mining', label: 'Process Mining', icon: 'bar-chart-3' },
{ id: 'agents', label: 'Agentes', icon: 'briefcase' },
{ id: 'roai', label: 'RoAI', icon: 'trending-up' },
{ id: 'rotinas', label: 'Rotinas', icon: 'clock' },
{ id: 'achados', label: 'Achados', icon: 'file-text' },
];
return (
U
UpFlux Studio
/
{editingName ? (
setDraftName(e.target.value)}
onBlur={commitName}
onKeyDown={(e) => {
if (e.key === 'Enter') commitName();
if (e.key === 'Escape') setEditingName(false);
}}
/>
) : (
{demo?.name || `Demo · ${state.cosmetic.company}`}
{demo?.version && {demo.version} }
)}
Salvo automaticamente
{screens.map((s) => (
setState({ ...state, activeScreen: s.id })}
>
{s.label}
))}
setState({ ...state, editMode: !state.editMode })}
title="Permite clicar e editar valores direto no preview"
>
{state.editMode ? 'Sair da edição' : 'Modo edição'}
setState({ ...state, previewMode: true })}
>
Visualizar
setState({ ...state, showShareModal: true })}
>
Compartilhar
{(state.vars?.ae_nome || AE.name).split(/\s+/).slice(0,2).map(s => s[0] || '').join('').toUpperCase()}
);
}
/* ---------- Canvas toolbar (above the browser frame) ---------- */
function CanvasToolbar({ state, setState }) {
const layers = [
{ n: 1, label: 'Cosmética', key: 'L1' },
{ n: 2, label: 'Dados', key: 'L2' },
{ n: 3, label: 'Persona', key: 'L3' },
{ n: 4, label: 'Variáveis', key: 'L4' },
{ n: 5, label: 'Guiado', key: 'L5' },
];
return (
Camadas aplicadas
{layers.map((L) => {
const lvlState = state.levels[L.n];
const on = lvlState && lvlState.enabled;
return (
setState({
...state,
levels: { ...state.levels, [L.n]: { ...lvlState, enabled: !on } },
expanded: state.expanded === L.n ? state.expanded : L.n,
})}
title={`Camada ${L.n} · ${L.label}`}
>
{L.n}. {L.label}
);
})}
Preview ao vivo · {state.activeScreen}
−
Ajustado
+
);
}
/* ---------- The browser frame around the Nous preview ---------- */
function BrowserFrame({ state, setState, level4Open, onOpenFullMap, hideChrome }) {
const personaObj = PERSONAS.find(p => p.id === state.persona.activeId) || PERSONAS[0];
const bodyRef = React.useRef(null);
const [scrollY, setScrollY] = React.useState(0);
/* Anchor hotspots to the page content: track the inner scroll container
so pins move with the content instead of floating over the viewport. */
React.useEffect(() => {
const body = bodyRef.current;
if (!body) return;
const findScroller = () => body.querySelector('.nous-scroll, .vt-main, .ach-screen, .ag-screen');
let scroller = findScroller();
const onScroll = () => setScrollY(scroller ? scroller.scrollTop : 0);
setScrollY(scroller ? scroller.scrollTop : 0);
if (scroller) scroller.addEventListener('scroll', onScroll, { passive: true });
return () => { if (scroller) scroller.removeEventListener('scroll', onScroll); };
}, [state.activeScreen, state.persona.activeId, state.threadActive]);
/* Global edit layer: when edit mode is on, make EVERY text/number node in
the preview directly editable (contentEditable), persisting overrides
into state.edits keyed by screen + ordinal + original text. */
React.useEffect(() => {
const body = bodyRef.current;
if (!body || !state.editMode) return;
const scope = body.querySelector('.nous-main') || body;
const SKIP = 'input,textarea,select,svg,.editable,.hotspot-overlay,.hotspot-callout,.persona-switcher,.edit-mode-banner';
const leaves = [];
const walk = (el) => {
for (const child of Array.from(el.children)) {
if (child.matches(SKIP) || child.closest('.editable')) continue;
const hasElementChildren = Array.from(child.children).some(c => !c.matches('svg'));
const txt = (child.textContent || '').trim();
if (!hasElementChildren && txt) leaves.push(child);
else if (hasElementChildren) walk(child);
}
};
walk(scope);
const cleanups = [];
leaves.forEach((el, i) => {
const orig = el.getAttribute('data-edit-orig') != null ? el.getAttribute('data-edit-orig') : el.textContent;
el.setAttribute('data-edit-orig', orig);
const key = 'live|' + state.activeScreen + '|' + i + '|' + String(orig).slice(0, 30);
if (state.edits && state.edits[key] !== undefined && document.activeElement !== el) {
el.textContent = state.edits[key];
}
try { el.contentEditable = 'plaintext-only'; } catch (e) { el.contentEditable = 'true'; }
el.classList.add('live-editable');
const onBlur = () => {
const v = el.textContent;
onEditsChangeRef.current(key, v);
};
const stop = (e) => e.stopPropagation();
el.addEventListener('blur', onBlur);
el.addEventListener('mousedown', stop);
el.addEventListener('click', stop);
cleanups.push(() => {
el.removeEventListener('blur', onBlur);
el.removeEventListener('mousedown', stop);
el.removeEventListener('click', stop);
el.contentEditable = 'false';
el.classList.remove('live-editable');
});
});
return () => cleanups.forEach(fn => fn());
}, [state.editMode, state.activeScreen, state.persona.activeId, state.threadActive, state.valueTracker, state.agents, state.edits]);
/* Stable ref to the edit setter so the layer effect doesn't re-bind on every render */
const onEditsChangeRef = React.useRef(() => {});
onEditsChangeRef.current = (key, value) => {
setState({ ...state, edits: { ...(state.edits || {}), [key]: value } });
};
const urlPath = state.activeScreen === 'chat' ? 'chat'
: state.activeScreen === 'mining' ? 'process-mining'
: state.activeScreen === 'agents' ? 'agentes'
: state.activeScreen === 'rotinas' ? 'rotinas'
: 'roai';
const screenHotspots = state.hotspots.filter(h => h.screen === state.activeScreen);
/* The callout only appears when the user explicitly clicks a pin —
and only the most recently clicked one. Default = no callout open. */
const openCallout = state.openHotspotId
? screenHotspots.find(h => h.id === state.openHotspotId)
: null;
return (
{!hideChrome && (
nous.upflux.ai/
{urlPath}
?demo={state.cosmetic.company.toLowerCase()}
)}
{
if (id === 'chat' && opts && opts.newConversation) {
setState({ ...state, activeScreen: 'chat', conversation: [], activeCategory: null });
} else {
setState({ ...state, activeScreen: id });
}
}}
level4Open={level4Open}
onOpenFullMap={onOpenFullMap}
onSetState={setState}
/>
{state.persona.branchEnabled && state.levels[3].enabled && (() => {
const displayName = (state.vars?.persona_nome && String(state.vars.persona_nome).trim()) || personaObj.name;
return (
Visualizando como
{displayName.split(' ')[0]} ({personaObj.role.split(' ')[0]})
);
})()}
{state.levels[5].enabled && screenHotspots.map((h, i) => {
const kind = window.STUDIO_DATA.HOTSPOT_KINDS[h.kind] || window.STUDIO_DATA.HOTSPOT_KINDS.tooltip;
return (
{
/* Click toggles: open if closed, close if already open */
e.stopPropagation();
setState({
...state,
openHotspotId: state.openHotspotId === h.id ? null : h.id,
showHotspotPreview: h.id,
});
}}
onMouseDown={(e) => {
/* Drag the pin to reposition */
e.preventDefault();
const body = e.currentTarget.closest('.browser-body');
if (!body) return;
const rect = body.getBoundingClientRect();
const onMove = (ev) => {
const nx = Math.max(0.04, Math.min(0.96, (ev.clientX - rect.left) / rect.width));
const ny = Math.max(0.04, Math.min(0.96, (ev.clientY - rect.top) / rect.height));
setState({
...state,
showHotspotPreview: h.id,
hotspots: state.hotspots.map(x =>
x.id === h.id ? { ...x, x: nx, y: ny, _std: false } : x),
});
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}}
>
{i + 1}
);
})}
{state.levels[5].enabled && openCallout && (() => {
const kind = window.STUDIO_DATA.HOTSPOT_KINDS[openCallout.kind] || window.STUDIO_DATA.HOTSPOT_KINDS.tooltip;
return (
);
})()}
{state.editMode && (
Modo edição · clique em qualquer texto ou número
setState({ ...state, editMode: false })}>
Concluir
)}
);
}
/* ---------- Full-map modal (Process Mining) ---------- */
function FullMapModal({ state, onClose }) {
return (
e.stopPropagation()}>
Mapa completo · 1 — Compras ao Pagamento
451.756 casos · 38 variantes · janela 90 dias · {state.cosmetic.company}
Cobertura: 85,77% {' '}
dos casos representados nesta visão simplificada.
Fechar
);
}
/* ---------- State encoding for shareable URL ---------- */
function encodeState(state) {
/* Strip transient fields that shouldn't be in a shareable URL */
const payload = {
activeScreen: state.activeScreen,
cosmetic: state.cosmetic,
data: state.data,
persona: state.persona,
vars: state.vars,
hotspots: state.hotspots,
levels: state.levels,
edits: state.edits,
};
try {
const json = JSON.stringify(payload);
/* URL-safe base64 */
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
} catch (e) { return ''; }
}
function decodeState(hash) {
try {
const padded = hash + '='.repeat((4 - hash.length % 4) % 4);
const json = decodeURIComponent(escape(atob(padded.replace(/-/g, '+').replace(/_/g, '/'))));
return JSON.parse(json);
} catch (e) { return null; }
}
/* Shared links (hash mode) carry only a subset of state. Fill every
missing branch from DEFAULT_STATE so the prospect viewer never reads
an undefined key (e.g. state.agents.activeTab on the Agentes screen). */
function hydrateViewerState(s) {
const base = window.STUDIO_DATA.DEFAULT_STATE;
const src = s || {};
return {
...base,
...src,
cosmetic: { ...base.cosmetic, ...(src.cosmetic || {}) },
persona: { ...base.persona, ...(src.persona || {}) },
vars: { ...base.vars, ...(src.vars || {}) },
data: { ...base.data, ...(src.data || {}) },
agents: src.agents || base.agents,
valueTracker: src.valueTracker || base.valueTracker,
discovery: src.discovery || base.discovery,
recentsByPersona: src.recentsByPersona || base.recentsByPersona,
levels: src.levels || base.levels,
hotspots: src.hotspots || base.hotspots,
edits: src.edits || {},
drilldown: { open: false },
showDemoBadge: src.showDemoBadge !== undefined ? src.showDemoBadge : base.showDemoBadge,
};
}
function copyToClipboard(text) {
/* Modern API first */
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).then(() => true).catch(() => fallbackCopy(text));
}
return Promise.resolve(fallbackCopy(text));
}
function fallbackCopy(text) {
/* Works in sandboxed iframes where clipboard API is blocked */
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
ta.setAttribute('readonly', '');
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, text.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
/* ---------- Share modal ---------- */
const PROD_DOMAIN = 'demo.upflux.ai';
function ShareModal({ state, demo, onClose }) {
const encoded = React.useMemo(() => encodeState(state), [state]);
/* Live URL — actually works because this HTML serves itself */
const liveUrl = `${location.origin}${location.pathname}#demo=${encoded}`;
/* Prod URL — what it'll look like after deploying to Hostinger */
const prodUrl = `https://${PROD_DOMAIN}/#demo=${encoded}`;
const shareUrl = liveUrl;
const [copied, setCopied] = React.useState(false);
const [downloaded, setDownloaded] = React.useState(false);
const [jsonDownloaded, setJsonDownloaded] = React.useState(false);
const urlRef = React.useRef(null);
/* Slug derived from demo name (clean named URL) */
const slug = (demo?.id || state.cosmetic.company || 'demo')
.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const slugUrl = `https://${PROD_DOMAIN}/d/${slug}`;
const downloadJson = () => {
const payload = { _slug: slug, _savedAt: new Date().toISOString(), state };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${slug}.json`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
setJsonDownloaded(true);
setTimeout(() => setJsonDownloaded(false), 2200);
};
const copy = () => {
/* Fire copy attempts (may silently fail in sandboxed iframes), then
always show optimistic feedback + select the text so user can Ctrl+C */
copyToClipboard(shareUrl);
if (urlRef.current) {
urlRef.current.focus();
urlRef.current.select();
}
setCopied(true);
setTimeout(() => setCopied(false), 2200);
};
const downloadHtml = () => {
/* Build a small standalone HTML file that:
1) sets the hash to the encoded state
2) redirects to the live demo URL (or shows instructions)
This is the lightweight version — a true single-file bundle would
inline all assets. */
const html = `
Demo Nous · ${state.cosmetic.company}
Demo Nous — ${state.cosmetic.company}
Abrindo a demo personalizada... Se não redirecionar automaticamente:
${shareUrl}
`;
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `demo-nous-${state.cosmetic.company.toLowerCase()}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloaded(true);
setTimeout(() => setDownloaded(false), 2200);
};
const openInNewTab = () => window.open(shareUrl, '_blank', 'noopener');
return (
e.stopPropagation()}>
Compartilhar demo · {state.cosmetic.company}
5 níveis aplicados · persona{' '}
{(PERSONAS.find(p => p.id === state.persona.activeId) || PERSONAS[0]).name.split(' ')[0]}
{' · '}{state.activeScreen}
Link de teste (atual)
Funciona já — pode mandar para o prospect
e.target.select()}
onClick={(e) => e.target.select()}
/>
{copied ? 'Copiado!' : 'Copiar'}
Abrir em nova aba
Estado da demo encodado no hash · {demo?.version || window.STUDIO_DATA.STUDIO_VERSION}.
URL própria por slug (recomendado)
Link curto e limpo. Requer subir o JSON em /demos/
e.target.select()}
/>
copyToClipboard(slugUrl)} title="Copiar URL por slug">
{jsonDownloaded ? 'Baixado!' : `Salvar ${slug}.json`}
Suba o arquivo em /public_html/demo/demos/ via File Manager. O link /d/{slug} passa a funcionar.
Alternativas
{downloaded ? 'Baixado!' : 'Download HTML standalone'}
Arquivo .html que abre a demo · ideal para email
alert('Em breve: gravação automática do tour com a voz do AE.')}>
Gravar tour de vídeo (MP4)
Felipe narra cada tela · até 5 min
alert('Em breve: envio direto por email + tracking de interação.')}>
Convidar por email
Adicionar destinatários · notificar interação
O que o prospect vai ver
Nous personalizado para {state.cosmetic.company} ,
abrindo na tela {state.activeScreen} como{' '}
{(PERSONAS.find(p => p.id === state.persona.activeId) || PERSONAS[0]).role} .
Sem chrome do Studio.
Cancelar
{copied ? 'Link copiado' : 'Copiar e enviar'}
);
}
/* ---------- Preview-only mode (no studio chrome) ---------- */
function PreviewMode({ state, setState, isProspect, onBackToLibrary }) {
return (
U
{isProspect
? <>Demo personalizada · UpFlux Nous >
: 'Pré-visualização · como o prospect verá'}
{state.cosmetic.company}
{!isProspect && onBackToLibrary && (
Biblioteca
)}
{!isProspect && (
setState({ ...state, previewMode: false })}>
Voltar ao editor
)}
{isProspect && (
Conhecer UpFlux
)}
setState({ ...state, showFullMap: true })}
hideChrome={false}
/>
);
}
/* ---------- App ---------- */
const today = () => new Date().toISOString().slice(0, 10);
const uid = () => Math.random().toString(36).slice(2, 9);
const STUDIO_PASSWORD = 'upflux2026';
function App() {
/* --- Auth ----------------------------------------------------- */
const [authed, setAuthed] = React.useState(() => {
try { return sessionStorage.getItem('studio_auth') === 'ok'; } catch { return false; }
});
/* --- Library (localStorage with seed fallback) ---------------- */
const [library, setLibrary] = React.useState(() => {
try {
const seed = window.STUDIO_DATA.LIBRARY_SEED;
const raw = localStorage.getItem('studio_library_v1');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
/* Merge in any seed demos the stored library is missing
(e.g. new standard environments added in an update),
without touching the user's existing edits. Tracks
removals so deliberately-deleted seeds don't reappear. */
let removed = [];
try { removed = JSON.parse(localStorage.getItem('studio_library_removed_v1') || '[]'); } catch { removed = []; }
const haveIds = new Set(parsed.map(d => d.id));
const missing = seed.filter(s => !haveIds.has(s.id) && !removed.includes(s.id));
return missing.length ? [...parsed, ...missing] : parsed;
}
}
} catch {}
return window.STUDIO_DATA.LIBRARY_SEED;
});
React.useEffect(() => {
try { localStorage.setItem('studio_library_v1', JSON.stringify(library)); } catch {}
}, [library]);
/* --- Hash / slug demo (prospect viewer mode, bypasses auth) ----- */
const hashDemoRef = React.useRef(undefined);
const slugRef = React.useRef(undefined);
if (hashDemoRef.current === undefined) {
const m = (location.hash || '').match(/#demo=([\w-]+)/);
hashDemoRef.current = m ? decodeState(m[1]) : null;
/* Detect /d/ path or ?slug= query for clean named URLs */
const pathM = (location.pathname || '').match(/\/d\/([\w-]+)/);
const qsM = new URLSearchParams(location.search).get('slug');
slugRef.current = pathM ? pathM[1] : (qsM || null);
}
/* Async-loaded demo fetched from /demos/.json */
const [slugDemo, setSlugDemo] = React.useState(undefined); /* undefined=loading, null=fail */
React.useEffect(() => {
if (!slugRef.current || hashDemoRef.current) { setSlugDemo(null); return; }
let alive = true;
fetch(`demos/${slugRef.current}.json`) .then(r => { if (!r.ok) throw new Error('not found'); return r.json(); })
.then(j => { if (alive) setSlugDemo(j && j.state ? j.state : j); })
.catch(() => { if (alive) setSlugDemo(null); });
return () => { alive = false; };
}, []);
/* --- Currently edited demo ------------------------------------ */
const [currentDemoId, setCurrentDemoId] = React.useState(null);
const currentDemo = library.find(d => d.id === currentDemoId);
/* setState that targets the current demo's state field */
const setDemoState = (next) => {
setLibrary(lib => lib.map(d => {
if (d.id !== currentDemoId) return d;
const newState = typeof next === 'function' ? next(d.state) : next;
return {
...d,
state: newState,
updatedAt: today(),
version: window.STUDIO_DATA.STUDIO_VERSION,
};
}));
};
/* For the EditsProvider that mutates demo.state.edits specifically */
const onEditsChange = (newEdits) => {
setDemoState(s => ({ ...s, edits: newEdits }));
};
/* --- Library actions ----------------------------------------- */
const openDemo = (id) => {
/* Clear transient UI flags on every open */
setLibrary(lib => lib.map(d => d.id === id ? {
...d,
state: {
...d.state,
__demoId: id,
editMode: false,
previewMode: false,
showShareModal: false,
showFullMap: false,
drilldown: { open: false },
/* Seed the GLOBAL standard hotspots; the demo's own win per
screen, standards fill any screen not yet personalized. */
hotspots: window.STUDIO_DATA.applyHotspots(d.state.hotspots),
}
} : d));
setCurrentDemoId(id);
};
const cloneDemo = (id) => {
const src = library.find(d => d.id === id);
if (!src) return;
const newId = `${src.prospect.toLowerCase().replace(/\s/g, '')}-${uid()}`;
const copy = {
...JSON.parse(JSON.stringify(src)),
id: newId,
name: `${src.name} (cópia)`,
status: 'draft',
version: window.STUDIO_DATA.STUDIO_VERSION,
createdAt: today(),
updatedAt: today(),
views: 0,
shareCount: 0,
};
setLibrary([copy, ...library]);
openDemo(newId);
};
const deleteDemo = (id) => {
setLibrary(library.filter(d => d.id !== id));
/* Remember deleted seed demos so the merge-on-load doesn't re-add them. */
try {
const seedIds = new Set(window.STUDIO_DATA.LIBRARY_SEED.map(s => s.id));
if (seedIds.has(id)) {
const removed = JSON.parse(localStorage.getItem('studio_library_removed_v1') || '[]');
if (!removed.includes(id)) localStorage.setItem('studio_library_removed_v1', JSON.stringify([...removed, id]));
}
} catch {}
};
const createNewDemo = (prospectName) => {
const newId = `${(prospectName || 'novo').toLowerCase().replace(/\s/g, '')}-${uid()}`;
const fresh = JSON.parse(JSON.stringify(window.STUDIO_DATA.DEFAULT_STATE));
fresh.cosmetic.company = prospectName || 'Novo prospect';
fresh.cosmetic.logoMonogram = (prospectName || 'N').charAt(0).toUpperCase();
fresh.vars.empresa = prospectName || 'Novo prospect';
const entry = {
id: newId,
name: prospectName || 'Nova demo',
prospect: prospectName || 'Novo prospect',
industry: '—',
persona: 'Marcos Silveira',
personaRole: 'Diretor de Operações',
owner: 'Felipe Andrade',
ownerInitials: 'FA',
status: 'draft',
version: window.STUDIO_DATA.STUDIO_VERSION,
createdAt: today(),
updatedAt: today(),
views: 0,
shareCount: 0,
accent: '#6D4ADD',
state: fresh,
thumbHint: 'Nova personalização',
};
setLibrary([entry, ...library]);
openDemo(newId);
};
/* --- Routing ------------------------------------------------- */
/* 0) Slug mode — /d/ loads demos/.json */
if (slugRef.current && !hashDemoRef.current) {
if (slugDemo === undefined) {
return (
);
}
if (slugDemo === null) {
return (
Demonstração não encontrada
O link /d/{slugRef.current} não existe ou foi removido.
);
}
return (
);
}
/* 1) Hash mode — prospect viewer */
if (hashDemoRef.current) {
const viewerState = {
...hashDemoRef.current,
editMode: false,
previewMode: true,
showShareModal: false,
showFullMap: false,
edits: hashDemoRef.current.edits || {},
};
return (
);
}
/* 2) Login */
if (!authed) {
return {
try { sessionStorage.setItem('studio_auth', 'ok'); } catch {}
setAuthed(true);
}} />;
}
/* 3) Library home */
if (!currentDemo) {
return (
{
try { sessionStorage.removeItem('studio_auth'); } catch {}
setAuthed(false);
}}
/>
);
}
/* 4) Studio editor for currentDemo */
const state = currentDemo.state;
const level4Open = state.expanded === 4;
const setState = setDemoState;
const backToLibrary = () => setCurrentDemoId(null);
if (state.previewMode) {
return (
{state.showShareModal && (
setState({ ...state, showShareModal: false })}
/>
)}
);
}
return (
{
setLibrary(lib => lib.map(d => d.id === currentDemoId
? { ...d, name: newName, updatedAt: today() }
: d));
}}
/>
setState({ ...state, showFullMap: true })}
/>
{state.showShareModal && (
setState({ ...state, showShareModal: false })}
/>
)}
{state.showFullMap && (
setState({ ...state, showFullMap: false })} />
)}
);
}
/* ---------- ViewerOnly (prospect view) ---------- */
function ViewerOnly({ initial }) {
const [state, setState] = React.useState(() => hydrateViewerState(initial));
/* phase: 'splash' → choose how to explore; 'explore' → free; 'tour' → guided */
const [phase, setPhase] = React.useState('splash');
const onEditsChange = (e) => setState(s => ({ ...s, edits: e }));
if (phase === 'splash') {
return (
setPhase('explore')}
onTour={() => setPhase('tour')}
/>
);
}
return (
{phase === 'tour' && (
setPhase('explore')} />
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );