PageSourceSearch

https://ensinosocial.com.br/assets/js/catalogo.js?v=1788083063

js ensinosocial.com.br collected 2026-10-02 00:26:31 UTC 24,086 bytes, 515 lines download raw bytes

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.