diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index db9cd8ad..c09f4dcb 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -27,6 +27,7 @@ class InvestmentDiscoveryController extends Controller return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters), 'territorySelection' => app(InvestmentGeography::class)->selection($filters), + 'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()), 'results' => $catalogue->query($filters)->paginate(12)->withQueryString(), 'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [], 'territorial' => $this->territorial($filters)]); @@ -106,6 +107,7 @@ class InvestmentDiscoveryController extends Controller unset($returnFilters['zone_id'], $returnFilters['from_dossier']); return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin, + 'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector), 'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null, 'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]); } diff --git a/app/Services/LegacyContent.php b/app/Services/LegacyContent.php index 0d50542e..37d50204 100644 --- a/app/Services/LegacyContent.php +++ b/app/Services/LegacyContent.php @@ -35,7 +35,7 @@ final class LegacyContent } /** @return array */ - public function article(string $slug = 'agronegocio'): array + public function article(string $slug = 'agronegocio', bool $includeRelated = true): array { abort_unless($this->available(), 404); $identity = config('legacy_content.articles.'.$slug); @@ -80,13 +80,26 @@ final class LegacyContent } preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks); $alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]); - $relatedArticles = array_values(array_filter($this->related($identity['group'], $slug), fn ($item) => !in_array($item['href'], $alreadyLinked, true))); + $relatedArticles = []; + if ($includeRelated) { + foreach ($this->related($identity['group'], $slug) as $item) { + if (in_array($item['href'], $alreadyLinked, true)) continue; + try { + $relatedArticles[] = $this->article($item['slug'], false); + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { + if ($error->getStatusCode() !== 404) throw $error; + } + } + } return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version, 'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'], 'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'], 'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'], - 'href' => '/'.$slug, 'html' => $localHtml, + 'href' => '/'.$slug, 'html' => $localHtml, 'summary' => self::excerpt($localHtml), + 'opportunities_href' => $sector ? '/oportunidades?sector='.rawurlencode($sector->slug) : null, + 'opportunities_label' => $sector ? 'Ver oportunidades de '.$sector->name : null, + 'has_opportunities' => count($opportunities) > 0, 'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug, 'group' => config('legacy_content.groups.'.$identity['group']), 'related_articles' => $relatedArticles, @@ -101,6 +114,22 @@ final class LegacyContent 'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false]; } + /** An excerpt of the displayed edition, never a claim about returns or availability. */ + public static function excerpt(string $html): string + { + $plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if (str_contains($plain, 'Hotelaria e resorts') && str_contains($plain, 'Ecoturismo') + && str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) { + return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.'; + } + preg_match_all('~]*>(.*?)

~isu', $html, $paragraphs); + foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) { + $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); + if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320); + } + return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + /** Resolve a library identity, preserving the already stored source URLs and versions. */ /** @return array */ public function articleForResource(int $resourceId): array diff --git a/app/Services/SectorInvestment.php b/app/Services/SectorInvestment.php index ba12c18e..796d33ca 100644 --- a/app/Services/SectorInvestment.php +++ b/app/Services/SectorInvestment.php @@ -37,19 +37,7 @@ final class SectorInvestment public function publicView(Sector $sector): array { $query = SectorOrganization::opportunities($sector); - $articles = []; - if (app(LegacyContent::class)->available()) { - foreach (self::CONTENT[$sector->slug] ?? [] as $slug) { - try { - $a = app(LegacyContent::class)->article($slug); - $articles[] = ['title' => $a['title'], 'href' => $a['href'], 'image' => $a['image'], 'sha256' => $a['sha256'], 'scope' => $slug === 'tech-fintech' && $sector->slug !== 'fintech' ? 'Especialização Fintech; não representa todo o setor.' : 'Conteúdo educativo relacionado; não é uma oportunidade concreta.']; - } catch (HttpException $e) { - if ($e->getStatusCode() !== 404) { - throw $e; - } - } - } - } + $articles = $this->guides($sector); $relations = []; foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $table => $foreign) { $ids = DB::table('opportunity_'.$table)->whereIn('opportunity_id', (clone $query)->select('id'))->pluck($foreign); @@ -74,6 +62,26 @@ final class SectorInvestment 'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null]; } + /** Published guides for an explicit sector; never inferred from proximity. */ + public function guides(?Sector $sector): array + { + if (! $sector) return []; + $articles = []; + if (app(LegacyContent::class)->available()) { + foreach (self::CONTENT[$sector->slug] ?? [] as $slug) { + try { + $a = app(LegacyContent::class)->article($slug, false); + $articles[] = \Illuminate\Support\Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']); + } catch (HttpException $e) { + if ($e->getStatusCode() !== 404) { + throw $e; + } + } + } + } + return $articles; + } + public function attach(User $actor, string $id, array $input): void { $data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate(); diff --git a/docs/investmoz/unification/educational-guides-qa-manifest.json b/docs/investmoz/unification/educational-guides-qa-manifest.json new file mode 100644 index 00000000..9db3d966 --- /dev/null +++ b/docs/investmoz/unification/educational-guides-qa-manifest.json @@ -0,0 +1,84 @@ +{ + "branch": "codex/educational-content-cards", + "base": "0e7d51f18fb424857fdaad48498a2fc43c044749", + "scope": "New QA delta; separate Tempo review before main", + "files": [ + { + "path": "app/Http/Controllers/InvestmentDiscoveryController.php", + "sha256": "693d1217ead894747c266a7295774b26a8b987f8faf06003ed60a6d3e15aca6c" + }, + { + "path": "app/Services/LegacyContent.php", + "sha256": "e856ba2a042e971933a401fd9544a50f6bc5c1541c28062719d840a8277da7b8" + }, + { + "path": "app/Services/SectorInvestment.php", + "sha256": "a0ee1c9bf864ef765ed70fc1bcdc839781d0bed7d705d991c801bfaa9bf7e4a0" + }, + { + "path": "docs/investmoz/unification/traceability-and-manual.md", + "sha256": "224a07ba0443a0a4a172fd5637ac311bfde64e21a0425aa12e0e729c89a456d6" + }, + { + "path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv", + "sha256": "78a9642b3b0985d447a790a32f1373b7c19c4e240b21490019e907cc9c6428d3" + }, + { + "path": "docs/investmoz/v2/auditoria-portal/backlog.md", + "sha256": "cbbcf81bf2f51de50e6f006dcf048bf0e1f47b51ab236f2b07895a627c1301d4" + }, + { + "path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv", + "sha256": "2652fc4558de01e307adf39b31ffe0897f32c475e3c77061627b03a6ab628227" + }, + { + "path": "resources/js/components/educational-article-card.tsx", + "sha256": "03a298406702cd3211c44fad1fdf79d455ad029d997d417a416f450d6d420b6e" + }, + { + "path": "resources/js/components/educational-guides.tsx", + "sha256": "2a2a52e50694e336f05544d51fc776a3bbc2b06d2ab61a1b3cdf1d261afca474" + }, + { + "path": "resources/js/components/sector-entry.tsx", + "sha256": "39004abf04f096b987d852d834ce9b27fdf557d2283db3b5868678d3ff0bff1a" + }, + { + "path": "resources/js/pages/investment/catalogue.tsx", + "sha256": "ced5e8d4b2910aeeaeb709d6ff0eb2d492f96d9b671d6ff832eea66d022a1f70" + }, + { + "path": "resources/js/pages/investment/detail.tsx", + "sha256": "719b3c008887676dd0b69ffe48bd5a64426de92362d92da8ad39412a578c51bc" + }, + { + "path": "resources/js/pages/portal/legacy.tsx", + "sha256": "dcd8462e940614f5935efa19d9c9444fe3432fbc704e9c05144508d5c944c90d" + }, + { + "path": "tests/Feature/EducationalGuidesTest.php", + "sha256": "aa9e3f62890f1d75f639ad4e7e05713c18112ec96a7fd49c864e5e007807d3cf" + } + ], + "build_manifest_sha256": "89c3ce0c67d9faf8cb3c5f7e9d74b3ad5d0ad70dff81ca2e37ca5cac7a471620", + "migrations": [], + "imports": [], + "publication": false, + "remote_operations": [], + "ai": false, + "evidence": { + "backend": "1 test, 8 assertions; exact isolated QA guard and transactions", + "types": "passed", + "build": "passed", + "desktop_width": 2048, + "mobile_width": 390, + "mobile_document_width": 375, + "keyboard": "visible focus and Enter opened guide", + "visual_data": "Read-only copy of existing public Tourism article/image; not synthetic" + }, + "limitations": [ + "Real Tourism article absent from current QA database; authenticated dossier recovery for that actual source not demonstrated", + "Visual preview router and public snapshot stay private outside Git; no scan/publication records fabricated" + ], + "rollback": "Restore previous sources/build through existing deployment chain, preserving database/uploads/permissions and later data" +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index b344144a..d1b6722d 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1521,3 +1521,20 @@ Rever a branch e o manifesto nominal; reconciliar novos commits dos programadore Não há migrations, importação ou publicação neste pacote. A reversão repõe as fontes/build da revisão anterior através da cadeia existente, preservando a base, uploads, referências, permissões e dados posteriores. Os helpers de contexto só transportam query local; não aceitam URL externa como destino de retorno. O manifesto lista fontes e hash do build QA; não inclui `.env`, credenciais, dumps, vendor/node_modules ou fixtures persistentes. Conferência final dos consumidores: negócios também conferido no navegador com pesquisa vazia; retirado o resumo local repetido, conservando o resumo partilhado. Fintech é identificado como especialização do Mercado Financeiro também nesse selector. Os cartões de zonas traduzem categoria, desenvolvimento, disponibilidade e estado de actividade, preservando os códigos e significados persistidos. A vista combinada não repete na lista de não localizados a ficha que já aparece na lista principal. + + +## Guias educativos relacionados — 20/10/2026 + +Delta independente de `0e7d51f18fb4`, que já foi integrado e confirmado na homologação. Este incremento permanece na branch `codex/educational-content-cards`, em QA, sujeito a revisão própria. Origem: pedido expresso da Tempo; nenhuma nova regra organizacional. + +O componente `EducationalArticleCard` usa imagem pública autorizada, título original, resumo da edição apresentada, autoria e «Ler o guia». Turismo descreve hotelaria, ecoturismo, infraestruturas e referências regionais presentes no artigo, sem repetir metas financeiras como promessa. Outros artigos usam um excerto do texto efectivo. A apresentação não altera originais, fontes, autoria, direitos, versões, publicação ou scan. A imagem continua sujeita aos filtros públicos existentes. A acção secundária só surge quando a consulta de oportunidades públicas do sector tem resultados; não se infere disponibilidade por menção no artigo. + +Consumidores: sector (`SectorEntry`), exploração editorial e leituras relacionadas (`portal/legacy`), catálogo de oportunidades com sector explícito (incluindo vista territorial/mapa) e ficha de oportunidade com sector. Um filtro de localização sem sector não determina um artigo; não se inventaram associações territoriais. Nos restantes catálogos territoriais não foi encontrado um cartão editorial equivalente a substituir. Não se alteraram cartões de anúncios, documentos ou entidades que não sejam recomendações de artigos. + +Após a leitura, mantém-se «Iniciar ou continuar uma ideia», com `/investimentos/a-partir-de/{slug}` e o contexto autorizado do dossier quando existente. Não há novo percurso, conta ou privilégio. As provas anteriores de gravação documental mantêm-se; não foram repetidas. + +Provas novas: `EducationalGuidesTest`, isolado por guarda QA, transacções e armazenamento fictício do teste, 1 teste / 8 asserções (excerto, autoria, destino, percurso, ausência de resultados, fonte não publicada e scan pendente). TypeScript, sintaxe PHP e build passaram. Capturas a 2048 px e 390 px; a 390 px, conteúdo com 375 px, sem transbordo horizontal, foco visível e Enter no botão «Ler o guia». Imagem real carregada com 900 px de largura natural. A prévia HTTP `http://127.0.0.1:18570/qa/guias/turismo` usa uma cópia por leitura do artigo/imagem já públicos no destino; não usa texto ou imagem sintéticos nem escreve registos. O router privado e a cópia ficam fora do Git. O destino não foi alterado. + +Limitação: o artigo real não existe na base QA actual. A prévia permite conferir cartão e leitura; não comprova recuperação desse artigo num dossier autenticado nessa base. O URL original do percurso foi preservado e conferido pelo teste. Não se importaram dados ou fabricaram resultados para preencher essa lacuna. Capturas em `outputs/educational-guides-20261020/desktop.jpg` e `mobile.jpg` (ficheiros locais de prova). + +Gestão: editar e publicar pelos ecrãs existentes da biblioteca/editorial; o resumo acompanha a edição apresentada. A acção de oportunidades é calculada pelos registos publicados, não pelo editor. Disponibilização futura: revisão própria e integração em `main` pelo processo automático, após decisão. Sem migrations, importação ou publicação neste delta. Reversão pelas fontes/build anteriores, conservando base, ficheiros, permissões e dados posteriores. IA, oito lacunas contratuais e P7 separados. diff --git a/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv b/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv index ba423088..568a0b43 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv +++ b/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv @@ -7381,3 +7381,4 @@ P1,AUD-SEARCH-T08,QA — revisão Tempo pendente,Nenhuma alteração de relaçõ P1,AUD-SEARCH-T09,QA — revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/catalogue,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., P1,AUD-SEARCH-T10,QA — revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,"Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., P1,AUD-SEARCH-T11,QA — revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,"Situação jurídica, consulta e cálculo continuam independentes.",,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-GUIDE-CARD-20261020,QA review pending,New delta; not approved for main,Common educational card and existing source/context adapter,,Desktop/mobile/keyboard; public-state and source checks,,,,,,educational presentation,Tempo 20/10/2026; same manual,,,Review; real-source dossier recovery absent from QA dataset,,Integration approval for new delta,,,,1 test / 8 assertions; TypeScript/build; real public-source visual preview,,,,No import; no remote changes; no claim of new dossier proof, diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index 06d3c259..02740424 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -2990,3 +2990,8 @@ Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN ## Pesquisa transversal QA — 20/10/2026 Inventário finito AUD-SEARCH-T01–T11 na mesma matriz/CSV e comparação no manual. Correcções manuais na branch `codex/opportunity-search`, sem integração/remoto/publicação. Assistência NLP ainda não implementada: interpretador e prévia/confirmar da mesma query manual, 6–10 h para recorte local delimitado; modelo externo sujeito a decisão. As oito lacunas contratuais e P7 mantêm-se separados. Revisão Tempo pendente; as provas comuns não encerram requisitos de gestão ou publicação não alterados. + + +## Guias educativos relacionados — delta QA após 0e7d51f18fb4 + +AUD-GUIDE-CARD-20261020: componente comum com imagem autorizada, título, resumo do artigo publicado, autoria e «Ler o guia». Reutilizado em sectores, exploração editorial e leituras relacionadas; recomendado no catálogo por sector explícito e nas fichas de oportunidades. Sem associação territorial inferida. Botão secundário apenas com oportunidades públicas. QA: teste isolado 1/8, TypeScript/build e exemplos computador/móvel. Falta decisão sobre integração deste novo delta; a prévia visual de Turismo usa uma cópia por leitura do conteúdo público, sem importação. Recuperação documental num dossier com este acervo não demonstrada, pois o artigo não existe na base QA actual. Percurso e regras anteriores preservados; IA fora do lote. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 3ad6e7dc..f89fce73 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -10650,3 +10650,4 @@ AUD-SEARCH-T08,Consistência transversal da pesquisa e navegação — Corredore AUD-SEARCH-T09,Consistência transversal da pesquisa e navegação — Educação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/educacao,investment/catalogue — Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,QA — delta delimitado; revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Herda formulário e contagens concorrentes das oportunidades.,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,investment/catalogue; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. AUD-SEARCH-T10,Consistência transversal da pesquisa e navegação — Biblioteca,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/documentos,"portal/services; portal/document — Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,QA — delta delimitado; revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Dois resumos de contagem, filtros activos sem remoção individual, controlos sem opções e rodapé local repetido.",Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. AUD-SEARCH-T11,Consistência transversal da pesquisa e navegação — Legislação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/legislacao,portal/services; portal/document — Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,QA — delta delimitado; revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mesmo padrão da biblioteca; não deve expor um universo concorrente.,"Situação jurídica, consulta e cálculo continuam independentes.",portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,"Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Situação jurídica, consulta e cálculo continuam independentes.",,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-GUIDE-CARD-20261020,"Related educational cards: image, source summary, clear actions",Tempo 20/10/2026; independent delta after 0e7d51f18fb4,educational presentation,/sectores/{slug}; /explorar/{group}; articles; /oportunidades and detail,EducationalArticleCard; EducationalGuides; LegacyContent; SectorInvestment,QA only,1 test / 8 assertions; PHP syntax; TypeScript/build; desktop2048/mobile390; keyboard,QA review pending,Visual public-source snapshot; real article absent from QA DB; authenticated dossier recovery not demonstrated,,Source-derived summary; public images; conditional opportunity action; context preserved,P1,,,,,,,,,,,,,,,,,,presentation,,,,20/10/2026,,,,,,,,,,,,content; navigation; visual presentation,,Ready for QA review diff --git a/resources/js/components/educational-article-card.tsx b/resources/js/components/educational-article-card.tsx new file mode 100644 index 00000000..f4215425 --- /dev/null +++ b/resources/js/components/educational-article-card.tsx @@ -0,0 +1,73 @@ +import { Link } from '@inertiajs/react'; + +export type EducationalArticle = { + title: string; + href: string; + summary: string; + author?: string; + image: { href: string; alt: string } | null; + opportunities_href?: string | null; + opportunities_label?: string | null; + has_opportunities?: boolean; +}; + +export default function EducationalArticleCard({ + article, +}: { + article: EducationalArticle; +}) { + const focus = + 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800'; + return ( +
+ {article.image && ( + {article.image.alt} + )} +
+

+ Guia sectorial +

+

+ + {article.title} + +

+ {article.summary && ( +

+ {article.summary} +

+ )} + {article.author && ( +

+ Por {article.author} +

+ )} +
+ + Ler o guia + + {article.has_opportunities && + article.opportunities_href && ( + + {article.opportunities_label ?? + 'Ver oportunidades'} + + )} +
+
+
+ ); +} diff --git a/resources/js/components/educational-guides.tsx b/resources/js/components/educational-guides.tsx new file mode 100644 index 00000000..4c48ed12 --- /dev/null +++ b/resources/js/components/educational-guides.tsx @@ -0,0 +1,27 @@ +import EducationalArticleCard, { + type EducationalArticle, +} from './educational-article-card'; + +export default function EducationalGuides({ + articles, +}: { + articles: EducationalArticle[]; +}) { + if (!articles.length) return null; + return ( +
+

Descubra onde investir

+
+ {articles.map((article) => ( + + ))} +
+
+ ); +} diff --git a/resources/js/components/sector-entry.tsx b/resources/js/components/sector-entry.tsx index 9ea2e5d3..d87469bf 100644 --- a/resources/js/components/sector-entry.tsx +++ b/resources/js/components/sector-entry.tsx @@ -1,11 +1,9 @@ import { Link } from '@inertiajs/react'; +import EducationalArticleCard, { + type EducationalArticle, +} from './educational-article-card'; export type SectorEntryData = { - articles: { - title: string; - href: string; - image: { href: string; alt: string } | null; - scope: string; - }[]; + articles: EducationalArticle[]; relations: { id: string; title: string; @@ -66,9 +64,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) { perfil e a modalidade já autorizados; se ainda não tem perfil, pode preparar a ideia e pedir o perfil de promotor.

-

- Conteúdo e possibilidades de negócio -

+

Descubra onde investir

{data.content_notice &&

{data.content_notice}

} {!data.articles.length && (

@@ -78,22 +74,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) { )}

{data.articles.map((a) => ( -
- {a.image && ( - {a.image.alt} - )} - - {a.title} - -

{a.scope}

-
+ ))}
{data.specialization_href && ( diff --git a/resources/js/pages/investment/catalogue.tsx b/resources/js/pages/investment/catalogue.tsx index 57c7e258..830edfec 100644 --- a/resources/js/pages/investment/catalogue.tsx +++ b/resources/js/pages/investment/catalogue.tsx @@ -1,3 +1,5 @@ +import EducationalGuides from '@/components/educational-guides'; +import type { EducationalArticle } from '@/components/educational-article-card'; import OpportunitySearchFilters from '@/components/opportunity-search-filters'; import SearchState from '@/components/search-state'; import { Link, router, useForm, usePage } from '@inertiajs/react'; @@ -14,6 +16,7 @@ type Feature = { properties: { id: number; title: string; location: string; url: string }; }; export default function Catalogue({ + guides = [], filters, references, results, @@ -22,6 +25,7 @@ export default function Catalogue({ territorySelection, catalogueUrl, }: { + guides?: EducationalArticle[]; filters: Record; references: References; results: { @@ -712,6 +716,7 @@ export default function Catalogue({ )} + ); } diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx index 799b62ca..661e3dbc 100644 --- a/resources/js/pages/investment/detail.tsx +++ b/resources/js/pages/investment/detail.tsx @@ -1,3 +1,5 @@ +import EducationalGuides from '@/components/educational-guides'; +import type { EducationalArticle } from '@/components/educational-article-card'; import { ClassificationSummary } from '@/components/opportunity-classification'; import { Link, router, usePage } from '@inertiajs/react'; import { useState } from 'react'; @@ -10,12 +12,14 @@ import TerritorialSources from '@/components/territorial-source-evidence'; import { Frame, card, button, money, groupLabels } from './shared'; import type { Opportunity, Context } from './shared'; export default function Detail({ + guides = [], record: r, context: c, zone_origin, returnUrl = '/oportunidades', dossierOrigin, }: { + guides?: EducationalArticle[]; record: Opportunity; context: Context; zone_origin?: { id: string; name: string } | null; @@ -309,6 +313,7 @@ export default function Detail({

)} + ); } diff --git a/resources/js/pages/portal/legacy.tsx b/resources/js/pages/portal/legacy.tsx index 9221abe1..0546ba69 100644 --- a/resources/js/pages/portal/legacy.tsx +++ b/resources/js/pages/portal/legacy.tsx @@ -1,6 +1,13 @@ import { Head, Link, useForm } from '@inertiajs/react'; +import EducationalArticleCard, { + type EducationalArticle, +} from '@/components/educational-article-card'; export type LegacyArticle = { + summary: string; + opportunities_href?: string | null; + opportunities_label?: string | null; + has_opportunities?: boolean; id: number; title: string; author: string; @@ -13,12 +20,7 @@ export type LegacyArticle = { landing_href: string; start_href: string; group: { label: string; heading: string }; - related_articles: { - id: number; - slug: string; - title: string; - href: string; - }[]; + related_articles: (EducationalArticle & { id: number })[]; html: string; library_href: string; image: { href: string; alt: string; source_url: string } | null; @@ -153,29 +155,10 @@ export default function LegacySector({ {match ? (
{articles.map((item) => ( -
-

- - {item.title} - -

-

- Conteúdo editorial preservado do portal - Tempo. -

- - Ler o artigo - -
+ article={item} + /> ))}
) : ( @@ -199,9 +182,7 @@ export default function LegacySector({
    {article.related_articles.map((item) => (
  • - - {item.title} - +
  • ))}
diff --git a/tests/Feature/EducationalGuidesTest.php b/tests/Feature/EducationalGuidesTest.php new file mode 100644 index 00000000..0ff8b610 --- /dev/null +++ b/tests/Feature/EducationalGuidesTest.php @@ -0,0 +1,59 @@ + true]); + $identity = config('legacy_content.articles.turismo'); + $body = '

Conteúdo sintético para conferir a apresentação do guia e preservar o texto da edição consultada.

'; + $bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => $identity['source_url'], 'content_html' => $body, + 'title' => 'Guia sintético', 'author' => 'Autor QA', 'legacy_id' => 171990, 'legacy_category' => 'Turismo', + 'published_at' => '2026-06-16', 'modified_at' => '2026-06-16'], JSON_THROW_ON_ERROR); + $id = (string) Str::uuid(); + Storage::disk('local')->put('guides/'.$id, $bytes); + DB::table('documents')->insert(['id' => $id, 'external_id' => $identity['external_id'], 'source_system' => 'synthetic-test', + 'file_name' => 'guide.json', 'media_type' => 'application/json', 'storage_key' => 'guides/'.$id, 'size_bytes' => strlen($bytes), + 'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean', + 'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']); + $resource = DB::table('portal_resources')->insertGetId(['document_id' => $id, 'title' => 'Guia sintético', 'category' => 'education', + 'topic' => 'turismo', 'published' => true, 'rights_status' => 'authorized', 'source_url' => $identity['source_url'], 'source_reference' => 'Synthetic isolated QA fixture', 'metadata_version' => 1]); + $sector = Sector::where('slug', 'turismo')->firstOrFail(); + $guides = app(SectorInvestment::class)->guides($sector); + $this->assertSame(strip_tags($body), $guides[0]['summary']); + $this->assertSame('Autor QA', $guides[0]['author']); + $this->assertSame('/turismo', $guides[0]['href']); + $this->assertSame('/oportunidades?sector=turismo', $guides[0]['opportunities_href']); + $this->assertSame('/investimentos/a-partir-de/turismo', app(LegacyContent::class)->article('turismo')['start_href']); + DB::table('opportunities')->where('sector_id', $sector->id)->update(['status' => 'draft']); + $this->assertFalse(app(SectorInvestment::class)->guides($sector)[0]['has_opportunities']); + DB::table('portal_resources')->where('id', $resource)->update(['published' => false]); + $this->assertSame([], app(SectorInvestment::class)->guides($sector)); + DB::table('portal_resources')->where('id', $resource)->update(['published' => true]); + DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending']); + $this->assertSame([], app(SectorInvestment::class)->guides($sector)); + } +}