1/** 2 * catalogo.js 3 * Alpine.js component para o catalogo de cursos 4 * Cards compactos com hover expand + infinite scroll 5 */ 6 7// Funcao global para compatibilidade (nao usada mais com infinite scroll) 8window.catalogoPagina = function(pagina) { 9 const el = document.querySelector('[x-data]'); 10 if (el && el._x_dataStack) { 11 el._x_dataStack[0].filtrar(pagina); 12 } 13}; 14 15function catalogoApp() { 16 return { 17 // Estado 18 categoriaId: '', 19 busca: '', 20 ordenar: 'relevancia', 21 origemEnsinoNacional: false, 22 pagina: 1, 23 total: 0, 24 totalPaginas: 1, 25 carregando: false, 26 carregandoMais: false, 27 erroCarregar: false, 28 temMais: true, 29 apiUrl: '', 30 abortController: null, 31 observer: null, 32 seed: 0, 33 urlsBonitas: false, 34 modoGratuito: false, 35 // Titulo do catalogo geral (so vem preenchido na rota /cursos-gratuitos): a busca 36 // derruba o modo e o H1 nao pode continuar dizendo "Cursos Gratuitos" em cima de 37 // curso pago. tituloGratis e capturado na 1a troca (o valor que esta na tela). 38 tituloGeral: '', 39 tituloGratis: '', 40 // Filtro de faixa de preco: *Draft = valor sendo digitado; sem Draft = valor APLICADO 41 // (o que vai pra API e o que o rotulo do dropdown mostra). aplicarPreco() copia um no outro. 42 precoMin: '', 43 precoMax: '', 44 precoMinDraft: '', 45 precoMaxDraft: '', 46 categoriaSlugs: {}, 47 categoriaNomes: {}, 48 ordenarNomes: {}, 49 50 init() { 51 const config = window.__catalogoConfig || {}; 52 this.apiUrl = config.apiUrl || ''; 53 this.categoriaId = String(config.categoriaId || ''); 54 this.busca = config.busca || ''; 55 this.ordenar = config.ordenar || 'relevancia'; 56 this.total = config.total || 0; 57 this.totalPaginas = config.totalPaginas || 1; 58 this.pagina = config.pagina || 1; 59 this.seed = config.seed || 0; 60 this.urlsBonitas = config.urlsBonitas || false; 61 this.modoGratuito = config.modoGratuito || false; 62 this.tituloGeral = config.tituloGeral || ''; 63 this.precoMin = String(config.precoMin || ''); 64 this.precoMax = String(config.precoMax || ''); 65 this.precoMinDraft = this.precoMin; 66 this.precoMaxDraft = this.precoMax; 67 this.categoriaSlugs = config.categoriaSlugs || {}; 68 this.categoriaNomes = config.categoriaNomes || {}; 69 this.ordenarNomes = config.ordenarNomes || {}; 70 this.origemEnsinoNacional = config.origem === 'ensinonacional'; 71 this.temMais = this.pagina < this.totalPaginas; 72 73 // Setup IntersectionObserver para infinite scroll 74 this._setupObserver(); 75 76 // Animar os cards já renderizados pelo servidor na carga inicial 77 // (o CSS inicia todos com opacity:0; sem isso, a grade fica invisÃvel) 78 this.$nextTick(() => { 79 const gridEl = document.getElementById('catalogo-grid-conteudo'); 80 if (gridEl) { 81 const cards = gridEl.querySelectorAll('.cursos-grid > .curso-card'); 82 cards.forEach((card, idx) => { 83 setTimeout(() => { card.classList.add('animado'); }, idx * 80); 84 }); 85 } 86 }); 87 88 // Listener para back/forward do browser 89 window.addEventListener('popstate', () => { 90 const url = new URL(window.location.href); 91 // Detectar categoria pela URL amigavel ou query param 92 let catId = url.searchParams.get('categoria') || ''; 93 if (!catId && this.urlsBonitas) { 94 const match = url.pathname.match(/^\/categoria\/([a-zA-Z0-9_-]+)$/); 95 if (match) { 96 const slugBuscado = match[1]; 97 for (const [id, slug] of Object.entries(this.categoriaSlugs)) { 98 if (slug === slugBuscado) { catId = id; break; } 99 } 100 } 101 } 102 this.categoriaId = catId; 103 // O modo gratuito agora CAI sozinho quando o visitante busca â sem re-derivar 104 // aqui, o Voltar pro /cursos-gratuitos mostrava o catalogo
104inteiro debaixo do 105 // titulo "Cursos Gratuitos" (o estado do Alpine ficava preso no ultimo valor). 106 this.modoGratuito = url.pathname === '/cursos-gratuitos' 107 || url.searchParams.get('gratuito') === '1'; 108 this._tituloModo(this.modoGratuito); 109 this.busca = url.searchParams.get('busca') || ''; 110 this.ordenar = url.searchParams.get('ordenar') || 'relevancia'; 111 // Preco vem junto: sem isto o Voltar desfazia categoria/busca/ordem mas 112 // deixava a faixa de preco presa do estado anterior. 113 this.precoMin = url.searchParams.get('preco_min') || ''; 114 this.precoMax = url.searchParams.get('preco_max') || ''; 115 this.precoMinDraft = this.precoMin; 116 this.precoMaxDraft = this.precoMax; 117 this.filtrar(); 118 }); 119 }, 120 121 _setupObserver() { 122 this.observer = new IntersectionObserver((entries) => { 123 entries.forEach(entry => { 124 if (entry.isIntersecting && this.temMais && !this.carregandoMais && !this.carregando) { 125 this._carregarMais(); 126 } 127 }); 128 }, { 129 rootMargin: '300px' 130 }); 131 132 // Observar o sentinel (elemento no final do grid) 133 this.$nextTick(() => { 134 const sentinel = document.getElementById('catalogo-sentinel'); 135 if (sentinel) { 136 this.observer.observe(sentinel); 137 } 138 }); 139 }, 140 141 _observarSentinel() { 142 // Re-observar sentinel apos atualizar o DOM 143 const sentinel = document.getElementById('catalogo-sentinel'); 144 if (sentinel && this.observer) { 145 this.observer.disconnect(); 146 this.observer.observe(sentinel); 147 } 148 }, 149 150 filtrar() { 151 this.pagina = 1; 152 this.temMais = true; 153 this._executarBusca(true); 154 }, 155 156 // A toolbar e STICKY (fixa no topo), mas o spinner de "Buscando cursos..." nasce no 157 // topo de #catalogo-grid-wrapper. Quem rolou pra baixo e troca de categoria/ordem via o 158 // grid ESVAZIAR com o spinner acima da dobra -> da posicao dele "nada aconteceu" e ele 159 // re-clica (rage-click medido no catalogo). Rola a grade pra dentro da viewport SO quando 160 // o topo dela ja saiu por cima (usuario rolou) â sem incomodar quem ja esta no topo (ex.: 161 // digitando na busca). Respeita prefers-reduced-motion. 162 _rolarParaGrade() { 163 const wrapper = document.getElementById('catalogo-grid-wrapper'); 164 if (!wrapper) return; 165 if (wrapper.getBoundingClientRect().top < 0) { 166 const reduz = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 167 wrapper.scrollIntoView({ behavior: reduz ? 'auto' : 'smooth', block: 'start' }); 168 } 169 }, 170 171 // Filtro por CONTROLE discreto (categoria/ordenacao): rola pra grade antes de filtrar pra 172 // que o spinner de recarregamento fique visivel. A busca (input) NAO usa isto â quem 173 // digita ja esta com a toolbar no topo, e rolar a cada tecla seria pior. 174 filtrarControle() { 175 this._rolarParaGrade(); 176 this.filtrar(); 177 }, 178 179 tentarNovamente() { 180 // Retry apos falha de rede/servidor: refaz a busca atual (pagina 1, filtros vigentes) 181 this.erroCarregar = false; 182 this.filtrar(); 183 }, 184 185 async _executarBusca(substituir) { 186 if (substituir) { 187 this.carregando = true; 188 } else { 189 this.carregandoMais = true; 190 } 191 this.erroCarregar = false; 192 193 // Cancelar request anterior 194 if (this.abortController) { 195 this.abortController.abort(); 196 } 197 this.abortController = new AbortController(); 198 // Referencia local: o finally de um request ABORTADO nao pode apagar o 199 // estado (spinner/guards) do request mais novo que o substituiu 200 const controllerLocal = this.abortController; 201 202 // Montar URL â busca ignora categoria (busca em tudo) 203 const params = new URLSearchParams(); 204 const temBusca = this.busca && this.busca.length >= 2; 205 // A BUSCA E SEMPRE GERAL (2026-08-30): quem entra pelo modo /cursos-gratuitos 206 // (2 cursos gratuitos publicados na prod) e digita um termo ficava preso no 207 // subconjunto â "Nenhum curso encontrado" pra tudo que a casa tem, com a unica 208 // saida enterrada na secao "Mostrando" do menu de ordenacao. Buscar sai do modo; 209 // a barra de modo (catalogo.php) explica e oferece a volta. Espelhado no servidor: 210 // catalogo.php redireciona /cursos-gratuitos?busca=... pro catalogo
210inteiro e 211 // api/catalogo-cursos.php ignora o gratuito quando ha busca. 212 if (temBusca && this.modoGratuito) { 213 this.modoGratuito = false; 214 this._tituloModo(false); 215 } 216 if (this.categoriaId && !temBusca) params.set('categoria', this.categoriaId); 217 if (temBusca) params.set('busca', this.busca); 218 if (this.ordenar) params.set('ordenar', this.ordenar); 219 if (this.pagina > 1) params.set('pagina', this.pagina); 220 if (this.seed && this.ordenar === 'relevancia') params.set('seed', this.seed); 221 if (this.origemEnsinoNacional) params.set('origem', 'ensinonacional'); 222 if (this.modoGratuito) params.set('gratuito', '1'); 223 if (this.precoMin) params.set('preco_min', this.precoMin); 224 if (this.precoMax) params.set('preco_max', this.precoMax); 225 226 const url = this.apiUrl + '&' + params.toString(); 227 228 try { 229 const response = await fetch(url, { 230 signal: this.abortController.signal, 231 headers: { 'X-Requested-With': 'XMLHttpRequest' } 232 }); 233 if (!response.ok) throw new Error('HTTP ' + response.status); 234 const data = await response.json(); 235 236 const gridEl = document.getElementById('catalogo-grid-conteudo'); 237 238 if (substituir) { 239 // Substituir todo o conteudo (so se veio HTML valido â evita renderizar "undefined") 240 if (gridEl && typeof data.html_grid === 'string') gridEl.innerHTML = data.html_grid; 241 if (typeof data.total === 'number') this.total = data.total; 242 if (typeof data.total_paginas === 'number') this.totalPaginas = data.total_paginas; 243 this.temMais = this.pagina < this.totalPaginas; 244 245 // Animar novos cards em cascata 246 if (gridEl) { 247 const cards = gridEl.querySelectorAll('.cursos-grid > .curso-card'); 248 cards.forEach((card, idx) => { 249 setTimeout(() => { card.classList.add('animado'); }, idx * 150); 250 }); 251 } 252 253 // Atualizar URL do browser 254 this._atualizarUrl(); 255 } else { 256 // Append: inserir novos cards no grid existente (sem duplicatas) 257 const gridContainer = gridEl ? gridEl.querySelector('.cursos-grid') : null; 258 if (gridContainer && data.html_grid) { 259 // IDs já existentes no grid 260 const idsExistentes = new Set(); 261 gridContainer.querySelectorAll('.curso-card input[name="curso_id"]').forEach(input => { 262 idsExistentes.add(input.value); 263 }); 264 265 const temp = document.createElement('div'); 266 temp.innerHTML = data.html_grid; 267 const novosCards = temp.querySelectorAll('.curso-card'); 268 let delay = 0; 269 novosCards.forEach(card => { 270 const idInput = card.querySelector('input[name="curso_id"]'); 271 const cursoId = idInput ? idInput.value : null; 272 if (!cursoId || !idsExistentes.has(cursoId)) { 273 gridContainer.appendChild(card); 274 if (cursoId) idsExistentes.add(cursoId); 275 // Animar novos cards em cascata 276 setTimeout(() => { card.classList.add('animado'); }, delay); 277 delay += 150; 278 } 279 }); 280 } 281 if (typeof data.total_paginas === 'number') this.totalPaginas = data.total_paginas; 282 this.temMais = this.pagina < this.totalPaginas; 283 } 284 285 // Re-observar sentinel 286 this.$nextTick(() => { 287 this._observarSentinel(); 288 }); 289 290 } catch (err) { 291 if (err.name !== 'AbortError') { 292 console.error('Erro ao buscar cursos:', err); 293 // Falha real (rede/servidor): avisa o usuario em vez de deixar a grade 294 // antiga parada sem sinal â evita busca que "nao faz nada" + rage-click 295 this.erroCarregar = true; 296 } 297 } finally { 298 // So o request ATUAL desliga os flags â um abortado deixaria o novo 299 // sem spinner e liberaria o sentinel no meio de um filtro em voo 300 if (this.abortController === controllerLocal) { 301 this.carregando = false;
302 this.carregandoMais = false; 303 } 304 } 305 }, 306 307 async _carregarMais() { 308 if (!this.temMais || this.carregandoMais || this.carregando) return; 309 this.carregandoMais = true; 310 await new Promise(r => setTimeout(r, 1200)); 311 // Um filtro/busca comecou durante o delay: desistir sem incrementar a 312 // pagina â senao este append abortaria o filtro em voo e colaria a 313 // pagina 2 dos filtros novos por cima do grid antigo 314 if (this.carregando) { 315 this.carregandoMais = false; 316 return; 317 } 318 this.pagina++; 319 await this._executarBusca(false); 320 }, 321 322 _atualizarUrl() { 323 if (this.urlsBonitas) { 324 // URLs amigaveis: /cursos ou /categoria/{slug} 325 // Modo gratuito: basePath fixo em /cursos-gratuitos, categoria vira query param 326 const params = new URLSearchParams(); 327 // So grava a busca na URL a partir de 2 chars â mesmo limiar do fetch 328 // (temBusca). Abaixo disso a grade mostra o catalogo/categoria SEM o termo, 329 // entao gravar ?busca=<1char> fazia o F5/compartilhar/Voltar renderizar 330 // um LIKE %x% diferente do que estava na tela. 331 if (this.busca && this.busca.length >= 2) params.set('busca', this.busca); 332 if (this.ordenar && this.ordenar !== 'relevancia') params.set('ordenar', this.ordenar); 333 // A faixa de preco tambem vai pra URL: o servidor JA a le no carregamento 334 // (catalogo.php + CatalogoService), entao sem isso o filtro sumia no F5, no 335 // link compartilhado e no Voltar do browser â filtro aplicado que a barra 336 // mostra ativo mas a lista ignora depois de recarregar. 337 if (this.precoMin) params.set('preco_min', this.precoMin); 338 if (this.precoMax) params.set('preco_max', this.precoMax); 339 340 let basePath; 341 if (this.modoGratuito) { 342 basePath = '/cursos-gratuitos'; 343 if (this.categoriaId && this.categoriaSlugs[this.categoriaId]) { 344 params.set('categoria', this.categoriaId); 345 } 346 } else if (this.categoriaId && this.categoriaSlugs[this.categoriaId]) { 347 basePath = '/categoria/' + this.categoriaSlugs[this.categoriaId]; 348 } else { 349 basePath = '/cursos'; 350 } 351 352 const qs = params.toString(); 353 const novaUrl = basePath + (qs ? '?' + qs : ''); 354 history.pushState(null, '', novaUrl); 355 } else { 356 // Formato legado: /?a=cursos.catalogo&categoria=37 357 const url = new URL(window.location.href); 358 359 if (this.categoriaId) { 360 url.searchParams.set('categoria', this.categoriaId); 361 } else { 362 url.searchParams.delete('categoria'); 363 url.searchParams.delete('cat'); 364 } 365 366 // Mesmo limiar de 2 chars do fetch (temBusca): abaixo disso a busca nao 367 // filtra a grade, entao nao deve entrar na URL (senao o F5/Voltar diverge). 368 if (this.busca && this.busca.length >= 2) { 369 url.searchParams.set('busca', this.busca); 370 } else { 371 url.searchParams.delete('busca'); 372 } 373 374 url.searchParams.set('ordenar', this.ordenar); 375 376 // O modo gratuito pode ter caido na busca (busca e sempre geral): sem isto a 377 // URL legada seguia com &gratuito=1 e o F5/compartilhar devolvia o visitante 378 // pro modo que a tela ja tinha abandonado. 379 if (this.modoGratuito) { 380 url.searchParams.set('gratuito', '1'); 381 } else { 382 url.searchParams.delete('gratuito'); 383 } 384
385 if (this.precoMin) { 386 url.searchParams.set('preco_min', this.precoMin); 387 } else { 388 url.searchParams.delete('preco_min'); 389 } 390 if (this.precoMax) { 391 url.searchParams.set('preco_max', this.precoMax); 392 } else { 393 url.searchParams.delete('preco_max'); 394 } 395 396 url.searchParams.delete('pagina'); 397 398 history.pushState(null, '', url.toString()); 399 } 400 }, 401 402 // _atualizarContagemCategorias removido â dropdown customizado nao exibe contagem 403 404 // Troca o texto do H1 quando o modo gratuito cai/volta. Mexe SO no no de texto â o 405 // <span id="catalogoParaVoce"> ("Todas as categorias") e irmao dele e nao pode sumir â 406 // e so depois do typewriter (.typing-done): enquanto ele digita, quem manda e ele. 407 _tituloModo(gratis) { 408 if (!this.tituloGeral) return; 409 const h1 = document.querySelector('.catalogo-header h1'); 410 if (!h1 || !h1.classList.contains('typing-done')) return; 411 const no = h1.firstChild; 412 if (!no || no.nodeType !== 3) return; 413 if (!this.tituloGratis) this.tituloGratis = no.nodeValue; 414 no.nodeValue = gratis ? this.tituloGratis : this.tituloGeral; 415 }, 416 417 limparBusca() { 418 this.busca = ''; 419 this.filtrar(); 420 }, 421 422 buscarAgora(el) { 423 // Enter no mobile: busca JA (bypass do debounce) e fecha o teclado com blur() â 424 // sem isso o teclado tampava metade da tela e escondia os resultados (parecia que 425 // "nao fez nada"). Espelha limparBusca(): chama filtrar() direto. 426 if (el && typeof el.blur === 'function') el.blur(); 427 this.filtrar(); 428 }, 429 430 // Rotulo do gatilho do dropdown de preco (reflete o valor APLICADO, nao o draft) 431 precoLabel() { 432 var mi = String(this.precoMin || '').trim(); 433 var ma = String(this.precoMax || '').trim(); 434 if (!mi && !ma) return 'Preço'; 435 if (mi && ma) return 'R$ ' + mi + 'â' + ma; 436 if (ma) return 'Até R$ ' + ma; 437 return 'A partir de R$ ' + mi; 438 }, 439
440 // Normaliza um campo digitado: so digitos, sem negativo/decimal (a query casta pra float). 441 _precoLimpo(v) { 442 var n = String(v == null ? '' : v).replace(/[^\d]/g, ''); 443 return n; 444 }, 445 446 aplicarPreco() { 447 var mi = this._precoLimpo(this.precoMinDraft); 448 var ma = this._precoLimpo(this.precoMaxDraft); 449 // Min > Max: troca (evita resultado sempre vazio por engano do usuario) 450 if (mi && ma && parseInt(mi, 10) > parseInt(ma, 10)) { var t = mi; mi = ma; ma = t; } 451 this.precoMinDraft = mi; 452 this.precoMaxDraft = ma; 453 this.precoMin = mi; 454 this.precoMax = ma; 455 this.filtrar(); 456 }, 457 458 // Atalho de faixa (os chips do painel): joga os valores nos campos e aplica de uma vez. 459 // Escreve no *Draft antes de aplicar pra que os inputs mostrem a faixa escolhida â quem 460 // clica "R$100 a 150" e reabre o menu precisa VER de onde saiu o filtro pra poder ajustar. 461 definirPreco(min, max) { 462 this.precoMinDraft = String(min == null ? '' : min); 463 this.precoMaxDraft = String(max == null ? '' : max); 464 this.aplicarPreco(); 465 }, 466 467 limparPreco() { 468 this.precoMin = ''; 469 this.precoMax = ''; 470 this.precoMinDraft = ''; 471 this.precoMaxDraft = ''; 472 this.filtrar(); 473 } 474 }; 475} 476 477/** 478 * Feedback imediato ao "Adicionar ao carrinho" (money-path). 479 * O card faz um POST que navega pro carrinho; ate a navegacao comecar ha uma janela 480 * de "nada aconteceu" (pior no mobile lento) em que o visitante re-clica (rage-click 481 * medido no catalogo). Espelha o anti-duplo-submit do checkout (carrinho/checkout.php): 482 * marca o form como enviando, desabilita o botao e sinaliza aria-busy. Delegado no 483 * document pra cobrir tambem os cards inseridos pelo infinite scroll. Se o JS falhar, 484 * o form segue submetendo normal (progressive enhancement â nunca bloqueia a compra). 485 */ 486(function () { 487 'use strict'; 488 489 document.addEventListener('submit', function (e) { 490 var form = e.target; 491 if (!form || typeof form.querySelector !== 'function') return; 492 var btn = form.querySelector('.btn-add-cart'); 493 if (!btn) return; // so os forms de add-to-cart dos cards tem esse botao 494 if (form.dataset.enviando === '1') { e.preventDefault(); return; } 495 form.dataset.enviando = '1'; 496 btn.disabled = true; 497 btn.setAttribute('aria-busy', 'true'); 498 btn.style.opacity = '0.6'; 499 btn.style.cursor = 'wait'; 500 }); 501 502 // Voltar via bfcache (comprador volta do carrinho pra continuar navegando) restaura 503 // o DOM congelado â sem isto o botao ficaria preso desabilitado. Mesmo padrao do checkout. 504 window.addEventListener('pageshow', function (e) { 505 if (!e.persisted) return; 506 document.querySelectorAll('.btn-add-cart[aria-busy="true"]').forEach(function (btn) { 507 var form = btn.closest('form'); 508 if (form) delete form.dataset.enviando; 509 btn.disabled = false; 510 btn.removeAttribute('aria-busy'); 511 btn.style.opacity = ''; 512 btn.style.cursor = ''; 513 }); 514 }); 515})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.