// ============================================================ // YCI · Camada de PROTÓTIPO NAVEGÁVEL (modo cliente) // Envolve a mesma composição de seções/artboards do canvas, mas // renderiza um player de apresentação: device frame (mobile/desktop), // navegação sequencial, hotspots dos fluxos-chave, índice e guia. // Esconde as notas do designer. Toggle para revelar o canvas designer. // // Depende (window): React, ReactDOM, DesignCanvas, DCSection, DCArtboard, // e o mapa window.YCI_HOTSPOTS (hotspots.js). // ============================================================ (function () { const HOTSPOTS = (typeof window !== 'undefined' && window.YCI_HOTSPOTS) || {}; // ---- Comentários (Supabase via REST/anon, sem login) ---- const SB = () => (typeof window !== 'undefined' && window.YCI_SUPABASE && window.YCI_SUPABASE.url && window.YCI_SUPABASE.anonKey) ? window.YCI_SUPABASE : null; const sbHeaders = (cfg, extra) => Object.assign({ apikey: cfg.anonKey, Authorization: 'Bearer ' + cfg.anonKey, 'Content-Type': 'application/json' }, extra || {}); async function sbList(cfg) { const r = await fetch(cfg.url + '/rest/v1/yci_comments?board=eq.' + encodeURIComponent(cfg.board) + '&order=created_at.asc&select=*', { headers: sbHeaders(cfg) }); if (!r.ok) throw new Error('list ' + r.status); return r.json(); } async function sbInsert(cfg, row) { const r = await fetch(cfg.url + '/rest/v1/yci_comments', { method: 'POST', headers: sbHeaders(cfg, { Prefer: 'return=representation' }), body: JSON.stringify(row) }); if (!r.ok) throw new Error('insert ' + r.status); return (await r.json())[0]; } async function sbPatch(cfg, id, patch) { const r = await fetch(cfg.url + '/rest/v1/yci_comments?id=eq.' + id, { method: 'PATCH', headers: sbHeaders(cfg, { Prefer: 'return=representation' }), body: JSON.stringify(patch) }); if (!r.ok) throw new Error('patch ' + r.status); return (await r.json())[0]; } async function sbDelete(cfg, id) { const r = await fetch(cfg.url + '/rest/v1/yci_comments?id=eq.' + id, { method: 'DELETE', headers: sbHeaders(cfg) }); if (!r.ok) throw new Error('delete ' + r.status); } const fmtTime = (iso) => { try { return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); } catch { return ''; } }; // ---- pareamento mobile/desktop por convenção de id ---- const ALIAS = { 'home-hibrida': { base: 'home', device: 'mobile' }, 'home-desktop': { base: 'home', device: 'desktop' }, 'rev-kanban': { base: 'rev-kanban', device: 'desktop' }, // par de rev-kanban-m (id sem -d) 'vida-mag-index-d': { base: 'vida-mag', device: 'desktop' }, // desktop da Revista índice }; const DESKTOP_ONLY = new Set(['g-header', 'g-mega', 'g-footer', 'g-sticky', 'g-sponsor']); const SUFFIXES = [['-desktop', 'desktop'], ['-desk', 'desktop'], ['-d', 'desktop'], ['-mobile', 'mobile'], ['-mob', 'mobile'], ['-m', 'mobile']]; function classify(id) { if (ALIAS[id]) return ALIAS[id]; for (const [s, dev] of SUFFIXES) if (id.endsWith(s)) return { base: id.slice(0, -s.length), device: dev }; return { base: id, device: DESKTOP_ONLY.has(id) ? 'desktop' : 'mobile' }; } function cleanLabel(l) { if (!l) return ''; return String(l).replace(/\s*·\s*(Mobile|Desktop)\b.*$/i, '').trim() || String(l); } // Submenu de área (drill-down do menu) — espelha a IA/SUBNAV do design. // Clicar um item do menu (topo/drawer) abre um painel com as subpáginas. const SUBNAV = { 'O Clube': [ ['Abertura · 70 anos', 'clube-70'], ['Governança / Diretoria', 'clube-dir'], ['Clubes conveniados', 'rev-conv-list'], ['Meio Ambiente · Marina Viva', 'clube-ambiente'], ['Sedes & estrutura', 'clube-onde'], ['Estrutura / instalações', 'clube-estrutura'], ['Flats', 'rev-flats-m'], ['Imprensa · Press kit', 'clube-imprensa'], ], 'Vida': [ ['Esportes › Náutica & Vela', 'vida-esportes'], ['Semana de Vela · SIVI', 'naut-semana'], ['Agenda', 'vida-agenda'], ['Eventos sociais', 'eventos-sociais'], ['Gastronomia', 'gastronomia'], ['Newsletter semanal', 'vida-newsletter'], ['Notícias', 'rev-noticias'], ], 'Notícias': [ ['Portal de Notícias', 'rev-noticias'], ['Todas as notícias', 'vida-news'], ['Newsletter semanal', 'vida-newsletter'], ], 'Serviços': [ ['Índice de Serviços', 'serv-index'], ['Marina & Combustíveis', 'serv-marina'], ['Travessia da balsa', 'serv-travessia'], ['Taxas & Regulamentos', 'serv-taxas'], ['Horários & Links úteis', 'serv-horarios'], ], }; // Texto do item de menu → chave de área do SUBNAV. const MENU_AREAS = [ [/^o clube$/i, 'O Clube'], [/vida no iate clube/i, 'Vida'], [/^not[íi]cias$/i, 'Notícias'], [/^servi[çc]os$/i, 'Serviços'], ]; // ---- walk: (achata Fragments/arrays/nulls) ---- function collectSections(children) { const sections = []; React.Children.forEach(children, (sec) => { if (!sec || sec.type !== window.DCSection) return; const sid = sec.props.id ?? sec.props.title; const arts = []; const pushArt = (node) => { React.Children.forEach(node, (a) => { if (!a) return; if (Array.isArray(a)) { a.forEach(pushArt); return; } if (a.type === React.Fragment) { pushArt(a.props.children); return; } if (a.type === window.DCArtboard) { const id = a.props.id ?? a.props.label; if (id) arts.push({ id, label: a.props.label, el: a, width: a.props.width, height: a.props.height }); } }); }; pushArt(sec.props.children); sections.push({ id: sid, title: sec.props.title, subtitle: sec.props.subtitle, artboards: arts }); }); return sections; } function buildPages(sections) { const pages = [], byKey = {}, idxByArt = {}; sections.forEach((sec) => { sec.artboards.forEach((a) => { const { base, device } = classify(a.id); const key = sec.id + '::' + base; let pg = byKey[key]; if (!pg) { pg = { key, sectionId: sec.id, sectionTitle: sec.title, label: cleanLabel(a.label), mobile: null, desktop: null, labelSet: false, order: pages.length }; byKey[key] = pg; pages.push(pg); } pg[device] = a; if (device === 'mobile' || !pg.labelSet) { pg.label = cleanLabel(a.label); if (device === 'mobile') pg.labelSet = true; } idxByArt[a.id] = pg.order; }); }); return { pages, idxByArt }; } // ==================================================================== // Player de apresentação // ==================================================================== function Player({ pages, idxByArt, onDesigner }) { const startIdx = React.useMemo(() => { // deep-link via ?screen=ID ; senão Home try { const p = new URLSearchParams(location.search).get('screen'); if (p && idxByArt[p] != null) return idxByArt[p]; } catch {} return idxByArt['home-hibrida'] ?? 0; }, [idxByArt]); const [idx, setIdx] = React.useState(startIdx); const [device, setDevice] = React.useState('mobile'); const [indexOpen, setIndexOpen] = React.useState(false); const [mapOpen, setMapOpen] = React.useState(false); const [submenu, setSubmenu] = React.useState(null); // área aberta no submenu (drill-down) const [guideOpen, setGuideOpen] = React.useState(() => { try { return localStorage.getItem('yci-guide-seen') !== '1'; } catch { return true; } }); const [viewMode, setViewMode] = React.useState('full'); // 'full' (viewport cheio) | 'frame' (moldura) const [anchored, setAnchored] = React.useState([]); // comentários const sbCfg = SB(); const [comments, setComments] = React.useState([]); const [commentMode, setCommentMode] = React.useState(false); const [composing, setComposing] = React.useState(null); // {xp,yp,cx,cy} const [activePin, setActivePin] = React.useState(null); // {id,cx,cy} const [panelOpen, setPanelOpen] = React.useState(false); const [sbErr, setSbErr] = React.useState(false); const authorRef = React.useRef((() => { try { return localStorage.getItem('yci-author') || ''; } catch { return ''; } })()); const bodyInpRef = React.useRef(null); const nameInpRef = React.useRef(null); const stageRef = React.useRef(null); const viewportRef = React.useRef(null); const contentRef = React.useRef(null); const page = pages[idx]; const shownDevice = page[device] ? device : (page.mobile ? 'mobile' : 'desktop'); const art = page[shownDevice]; const isFallback = shownDevice !== device; const coordHots = ((art && HOTSPOTS[art.id]) || []).filter((h) => !/logo|entrar|jornada/i.test(h.label || '')); const allHots = [...anchored, ...coordHots]; const go = (d) => setIdx((i) => (i + d + pages.length) % pages.length); const goToArt = (id) => { const t = idxByArt[id]; if (t != null) { setIdx(t); setIndexOpen(false); setSubmenu(null); } }; const closeGuide = () => { setGuideOpen(false); try { localStorage.setItem('yci-guide-seen', '1'); } catch {} }; // ---- comentários: carregar do Supabase uma vez ---- React.useEffect(() => { if (!sbCfg) return; let off = false; sbList(sbCfg).then((rows) => { if (!off) setComments(rows || []); }).catch(() => { if (!off) setSbErr(true); }); return () => { off = true; }; }, []); const screenComments = art ? comments.filter((c) => c.screen_id === art.id) : []; const startComment = (e) => { if (!commentMode || !art) return; if (e.target.closest('.yci-cpin')) return; const r = contentRef.current.getBoundingClientRect(); const xp = Math.max(0, Math.min(100, (e.clientX - r.left) / r.width * 100)); const yp = Math.max(0, Math.min(100, (e.clientY - r.top) / r.height * 100)); setActivePin(null); setComposing({ xp, yp, cx: e.clientX, cy: e.clientY }); }; const cancelCompose = () => setComposing(null); const submitCompose = async () => { const body = (bodyInpRef.current && bodyInpRef.current.value || '').trim(); if (!body || !sbCfg || !art) { setComposing(null); return; } let author = authorRef.current; if (!author && nameInpRef.current) author = (nameInpRef.current.value || '').trim(); if (author) { authorRef.current = author; try { localStorage.setItem('yci-author', author); } catch {} } const row = { board: sbCfg.board, screen_id: art.id, device: shownDevice, x: composing.xp, y: composing.yp, author: author || 'Anônimo', body }; setComposing(null); try { const saved = await sbInsert(sbCfg, row); setComments((cs) => [...cs, saved]); } catch { setSbErr(true); } }; const toggleResolve = async (c) => { try { const u = await sbPatch(sbCfg, c.id, { resolved: !c.resolved }); setComments((cs) => cs.map((x) => x.id === c.id ? u : x)); } catch { setSbErr(true); } }; const removeComment = async (c) => { try { await sbDelete(sbCfg, c.id); setComments((cs) => cs.filter((x) => x.id !== c.id)); setActivePin(null); } catch { setSbErr(true); } }; const jumpToComment = (c) => { const t = idxByArt[c.screen_id]; if (t == null) return; const pg = pages[t]; if (pg.mobile && pg.mobile.id === c.screen_id) setDevice('mobile'); else if (pg.desktop && pg.desktop.id === c.screen_id) setDevice('desktop'); setIdx(t); setPanelOpen(false); setCommentMode(true); setTimeout(() => setActivePin({ id: c.id, cx: 0, cy: 0 }), 60); }; // ---- ajuste de zoom/altura do device frame (imperativo, sem loop) ---- const fit = React.useCallback(() => { const stage = stageRef.current, vp = viewportRef.current, content = contentRef.current; if (!stage || !vp || !content) return; const screen = content.querySelector('.wf-screen'); if (!screen) return; content.style.zoom = '1'; const sw = screen.offsetWidth, sh = screen.offsetHeight; const cs = getComputedStyle(stage); const availW = stage.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); const availH = stage.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); let zoom, vh; if (viewMode === 'full') { // viewport cheio: preenche a largura (no máx. 1:1), rola a altura toda zoom = Math.max(0.2, Math.min(1, availW / sw)); vh = availH; vp.style.width = (sw * zoom) + 'px'; vp.style.height = vh + 'px'; } else { const bz = shownDevice === 'mobile' ? { w: 16, h: 34 } : { w: 2, h: 36 }; zoom = Math.max(0.2, Math.min(1, (availW - bz.w) / sw)); vh = shownDevice === 'mobile' ? Math.min(availH - bz.h, 900) : (availH - bz.h); vp.style.width = (sw * zoom) + 'px'; vp.style.height = Math.max(140, Math.min(vh, sh * zoom)) + 'px'; } content.style.zoom = String(zoom); }, [shownDevice, viewMode]); // Hotspots de alta frequência ancorados aos elementos REAIS (precisão por // pixel, independente da altura da tela): logo → Home, "Entrar" → Login, e // as jornadas da Home. Os demais hotspots continuam por coordenada (mapa). // Tiles de jornada da Home (.wf-jt) → destino por texto. const TILE_MAP = [ [/sou s[óo]cio/i, 'socio-login-rec', 'Sou sócio → Área do Sócio'], [/associ/i, 'assoc-mob', 'Quero me associar → Associe-se'], [/visito/i, 'clube-70', 'Visito o clube → O Clube'], [/velejo|compito/i, 'vida-esportes', 'Velejo / compito → Esportes'], ]; // Tiles de hub (.wf-jt) ESCOPADOS por tela — evita ambiguidade (ex.: "Travessia" // existe em Serviços e no painel do Sócio com destinos diferentes). const HUB_TILES = { 'serv-index': [[/marina/i, 'serv-marina'], [/combust/i, 'serv-marina'], [/travessia/i, 'serv-travessia'], [/taxas/i, 'serv-taxas'], [/regulamento/i, 'serv-taxas'], [/links? [úu]te/i, 'serv-horarios']], 'socio-home': [[/documento/i, 'socio-docs'], [/ouvidoria/i, 'socio-ouv'], [/travessia|vagas|seco/i, 'socio-travessia']], 'naut-hub': [[/classes/i, 'esp-classes']], }; // Painel do sócio: abas do topo (7) + bottom-nav (.wf-botnav) → página (texto EXATO). // Sprint Revisão Crítica: nav estável — Enquetes/Entregas entram; "Mais" agrupa o resto. const SOCIO_NAV = [ [/^in[íi]cio$/i, 'socio-home'], [/^comunicados?$/i, 'socio-com'], [/^documentos?$/i, 'socio-docs'], [/^enquetes?$/i, 'rev-enquetes'], [/^entregas?$/i, 'rev-kanban'], [/^ouvidoria$/i, 'socio-ouv'], [/^[ée]tica$/i, 'socio-etica'], ]; // Coluna "Navegar" + Imprensa + sedes do rodapé → seção (por texto do link). // (Gastronomia saiu do rodapé na Revisão Crítica — vive sob Vida; entrada via Vida.) const FOOT_MAP = [ [/^o clube$/i, 'clube-70'], [/vida no iate clube/i, 'vida-esportes'], [/^not[íi]cias$/i, 'rev-noticias'], [/^servi[çc]os$/i, 'serv-index'], [/associe-se/i, 'assoc-mob'], [/imprensa/i, 'clube-imprensa'], [/ilhabela|s[ãa]o sebasti|saco do sombrio|sede sp/i, 'clube-onde'], ]; const baseId = (id) => (id || '').replace(/-d$/, ''); const navText = (el) => (el.textContent || '').replace(/[▾▼]/g, '').trim(); const measureAnchors = React.useCallback(() => { const content = contentRef.current; const screen = content && content.querySelector('.wf-screen'); if (!screen) { setAnchored([]); return; } const sr = screen.getBoundingClientRect(); if (!sr.width || !sr.height) { setAnchored([]); return; } const curId = baseId(art && art.id); const pct = (el) => { const r = el.getBoundingClientRect(); return { top: (r.top - sr.top) / sr.height * 100, left: (r.left - sr.left) / sr.width * 100, width: r.width / sr.width * 100, height: r.height / sr.height * 100 }; }; const out = []; const add = (el, to, label) => { if (el && to) out.push({ ...pct(el), to, label }); }; // logo → Início (apenas no topo) const logo = screen.querySelector('.wf-logo'); if (logo) { const p = pct(logo); if (p.top > -2 && p.top < 60) out.push({ ...p, to: 'home-hibrida', label: 'Logo · Início' }); } // "Entrar / Área do Sócio" (botão do header) → Login const entrar = Array.from(screen.querySelectorAll('.wf-btn')).find((b) => /(entrar|área do s[óo]cio)/i.test(b.textContent || '')); if (entrar) { const p = pct(entrar); if (p.top > -2 && p.top < 60) out.push({ ...p, to: 'socio-login-rec', label: 'Entrar · Área do Sócio' }); } // "Associe-se" (CTA do header) → Associe-se const assoc = Array.from(screen.querySelectorAll('.wf-btn')).find((b) => /^associe-se$/i.test((b.textContent || '').trim())); if (assoc) { const p = pct(assoc); if (p.top > -2 && p.top < 80) out.push({ ...p, to: 'assoc-mob', label: 'Associe-se' }); } // tiles de jornada da Home screen.querySelectorAll('.wf-jt').forEach((t) => { const m = TILE_MAP.find(([re]) => re.test(t.textContent || '')); if (m) add(t, m[1], 'Jornada · ' + m[2]); }); // MENU do topo (.wf-nav) → abre o SUBMENU da área (drill-down às subpáginas) screen.querySelectorAll('.wf-nav a').forEach((a) => { const m = MENU_AREAS.find(([re]) => re.test(navText(a))); if (m) out.push({ ...pct(a), menu: m[1], label: 'Menu · ' + navText(a) }); }); // HAMBÚRGUER (mobile) → abre o drawer const burger = screen.querySelector('.wf-burger'); if (burger) add(burger, 'g-menu-mob', 'Abrir menu'); // DRAWER mobile (g-menu-mob): área → submenu; "Área do Sócio" → login direto if (curId === 'g-menu-mob') { screen.querySelectorAll('.wf-h.sm').forEach((el) => { const t = navText(el), box = el.closest('.wf-between') || el; const m = MENU_AREAS.find(([re]) => re.test(t)); if (m) out.push({ ...pct(box), menu: m[1], label: 'Menu · ' + t }); else if (/área do s[óo]cio/i.test(t)) add(box, 'socio-login-rec', 'Menu · Área do Sócio'); }); } // TILES de hub (Serviços / painel do sócio) → página específica const hub = HUB_TILES[curId]; if (hub) screen.querySelectorAll('.wf-jt').forEach((t) => { const m = hub.find(([re]) => re.test(t.textContent || '')); if (m) add(t, m[1], 'Tile · ' + (t.textContent || '').replace(/acessar.*/i, '').trim()); }); // PAINEL DO SÓCIO (telas com SocioShell): abas do topo (.wf-h.sm) + bottom-nav // (.wf-botnav .it) → página, por texto EXATO; + "Sair" → Início. if (screen.querySelector('.wf-botnav')) { screen.querySelectorAll('.wf-botnav .it, .wf-h.sm').forEach((el) => { const m = SOCIO_NAV.find(([re]) => re.test((el.textContent || '').trim())); if (m) add(el, m[1], 'Sócio · ' + (el.textContent || '').trim()); }); const sair = Array.from(screen.querySelectorAll('.wf-data, .wf-t')).find((el) => /^sair$/i.test((el.textContent || '').trim())); if (sair) add(sair, 'home-hibrida', 'Sair → Início'); } // RODAPÉ: coluna "Navegar" + Imprensa (links de texto) → seção. Presente em // quase toda tela, dá navegação de site mesmo no mobile (sem o menu desktop). screen.querySelectorAll('.wf-foot .wf-t').forEach((el) => { const m = FOOT_MAP.find(([re]) => re.test((el.textContent || '').trim())); if (m) add(el, m[1], 'Rodapé · ' + (el.textContent || '').trim()); }); const assinar = Array.from(screen.querySelectorAll('.wf-foot .wf-btn')).find((b) => /assinar/i.test(b.textContent || '')); if (assinar) add(assinar, 'vida-newsletter', 'Rodapé · Newsletter'); // CTA "Quero me associar" (landing Associe-se) → fluxo de lead (LGPD) const leadCta = Array.from(screen.querySelectorAll('.wf-btn')).find((b) => /quero me associar/i.test(b.textContent || '')); if (leadCta) add(leadCta, 'rev-lead', 'Quero me associar → Lead'); setAnchored(out); }, [art]); React.useLayoutEffect(() => { fit(); measureAnchors(); if (viewportRef.current) viewportRef.current.scrollTop = 0; }, [idx, device, viewMode, fit, measureAnchors]); React.useEffect(() => { const onR = () => { fit(); measureAnchors(); }; window.addEventListener('resize', onR); window.addEventListener('load', onR); const ro = ('ResizeObserver' in window) ? new ResizeObserver(onR) : null; if (ro && contentRef.current) ro.observe(contentRef.current); const t = setTimeout(onR, 250); // re-medir após fontes/imagens (a altura muda) return () => { window.removeEventListener('resize', onR); window.removeEventListener('load', onR); if (ro) ro.disconnect(); clearTimeout(t); }; }, [fit, measureAnchors, idx, device]); // ---- teclado ---- React.useEffect(() => { const onKey = (e) => { if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } else if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } else if (e.key === 'Escape') { setIndexOpen(false); setGuideOpen(false); setMapOpen(false); setSubmenu(null); } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }); const onStageClick = (e) => { if (e.target.closest('.yci-hot') || e.target.closest('.yci-cpin') || e.target.closest('.yci-composer') || e.target.closest('.yci-cpop')) return; if (composing || activePin) { setComposing(null); setActivePin(null); } }; const Ico = ({ d, ...p }) => ( ); return (
{/* ---------- Barra ---------- */}
Bertolani Novo Site YCI Wireframe · Fase 1
{page.label} {idx + 1} / {pages.length} · {page.sectionTitle}
{sbCfg && ( )}
{/* ---------- Palco ---------- */}
e.stopPropagation()}> {viewMode === 'frame' && (shownDevice === 'mobile' ?
:
yci.org.br{art ? ' / ' + art.id : ''}
)}
{ if (composing) setComposing(null); if (activePin) setActivePin(null); }}>
{art ? art.el.props.children : null}
{allHots.map((h, i) => (
{screenComments.map((c, i) => ( ))}
{isFallback &&
{device === 'desktop' ? 'Desktop não desenhado · mobile-first' : 'Tela de desktop'}
}
{page.label} · {idx + 1}/{pages.length}
{/* ---------- Guia rápido ---------- */} {guideOpen && (
e.stopPropagation()}>

Como navegar

Protótipo de wireframe · baixa fidelidade
  1. Veja em Tela cheia ou com Moldura, e troque entre Mobile e Desktop no topo.
  2. Navegue clicando nos botões e links das telas, como num site — os fluxos principais já respondem. Use ← → ou o Índice para ir a qualquer tela.
  3. Comentar: clique em Comentar e depois em qualquer ponto da tela; Comentários lista tudo.
É um wireframe: sem cor, foto ou fonte final. O feedback aqui é sobre estrutura e fluxo — nem toda tela está ligada.
)} {/* ---------- Índice (árvore por sitemap) ---------- */} {indexOpen && ( <>
setIndexOpen(false)} /> { setIdx(t); setIndexOpen(false); }} /> )} {/* ---------- Mapa do site (visual) ---------- */} {mapOpen && ( { setIdx(t); setMapOpen(false); }} onClose={() => setMapOpen(false)} /> )} {submenu && ( goToArt(id)} onClose={() => setSubmenu(null)} /> )} {/* fecha composer/popover ao clicar fora */} {(composing || activePin) &&
{ setComposing(null); setActivePin(null); }} />} {/* ---------- Comentários: composer ---------- */} {composing && (
e.stopPropagation()} style={{ top: Math.min(composing.cy, window.innerHeight - 220), left: Math.min(composing.cx, window.innerWidth - 268) }}>
Novo comentário · {page.label}
{!authorRef.current && }