/* === 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 (
/ {editingName ? ( setDraftName(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditingName(false); }} /> ) : ( )} Salvo automaticamente
{screens.map((s) => ( ))}
{(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 ( ); })}
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 (
{kind.label} {' · '} {openCallout.title} {openCallout.videoDuration && ` · ${openCallout.videoDuration}`}
{openCallout.text}
{openCallout.cta && ( { if (!openCallout.link) e.preventDefault(); }} > {openCallout.cta} )}
); })()} {state.editMode && (
Modo edição · clique em qualquer texto ou número
)}
); } /* ---------- 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.
); } /* ---------- 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()} />
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()} />
Suba o arquivo em /public_html/demo/demos/ via File Manager. O link /d/{slug} passa a funcionar.
URL de produção (hash, funciona sem subir nada) Link longo, auto-contido. Funciona assim que o domínio estiver no ar
e.target.select()} />
Alternativas
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.
); } /* ---------- 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 && ( )} {!isProspect && ( )} {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 (
Carregando demonstração…
); } 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();