restaturacao da cor nornmal de porque incvestir em Mocambique

This commit is contained in:
2026-10-26 16:02:04 +02:00
parent ae0d74bb3a
commit 71551deb73
11 changed files with 315 additions and 124 deletions
@@ -71,6 +71,7 @@ class PortalServicesController extends Controller
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') {
$props['sectors'] = \App\Services\SectorOrganization::catalog();
$props['sectorDetails'] = $props['sectors']->map(fn (Sector $sector) => app(\App\Services\SectorInvestment::class)->page($sector))->values();
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
@@ -25,9 +25,7 @@ class PublicPortalController extends Controller
if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech';
$sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail();
return Inertia::render('portal/services', ['section' => 'sector', 'sector' => $sector->only(['id', 'name', 'slug', 'description', 'short_description']),
'sectorEntry' => array_key_exists($sector->slug, SectorInvestment::CONTENT) ? app(SectorInvestment::class)->publicView($sector) : null,
'opportunities' => \App\Services\SectorOrganization::opportunities($sector)->latest('published_at')->limit(12)->get(['id', 'title', 'slug', 'province', 'summary'])]);
return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]);
}
public function opportunity(string $slug): Response
+11
View File
@@ -34,6 +34,17 @@ final class SectorInvestment
return [...$sector->only(['id', 'name', 'slug']), 'href' => SectorOrganization::href($sector->slug)];
}
/** Public detail shared by the catalogue and the individual sector page. */
public function page(Sector $sector): array
{
return [
'sector' => $sector->only(['id', 'name', 'slug', 'description', 'short_description']),
'sectorEntry' => $this->publicView($sector),
'opportunities' => SectorOrganization::opportunities($sector)->latest('published_at')
->limit(12)->get(['id', 'title', 'slug', 'province', 'summary']),
];
}
public function publicView(Sector $sector): array
{
$query = SectorOrganization::opportunities($sector);
@@ -6471,3 +6471,20 @@ Fontes e inventário8143 reutilizados dos cotejos aceitesCBF16 eDecreto72/2013ar
### Porquê investir — fundo azul, 26/10/2026
Pedido expresso da Tempo: ajustar o fundo para azul bebé ou azul IBM. Escolha de apresentação: IBM Blue 20, #D0E2FF, da paleta oficial https://www.carbondesignsystem.com/building-blocks/foundations/color/code. Substitui #BBC0C6 apenas no bloco dos cinco parágrafos; a decisão anterior fica no histórico. Inventário do delta: mantidos texto escuro, geometria compacta, cinco parágrafos editoriais, 12 anteriores, sete cartões, 15 links e a disposição aprovada, sem perda confirmada. Sem alteração de schema, dados, configuração, permissões ou cálculos. Registo em V2-01 na matriz existente. Verificação deste azul concluída: build e diff passaram; prévia isolada em 1440 e 390 px confirmou rgb(208, 226, 255), equivalente a #D0E2FF, sem alteração da geometria, 17 parágrafos ou 15 links e sem transbordo. Captura de computador inspeccionada. Recibo em outputs/why-invest-blue-20261026/layout.json. Sem prova do activo; alteração local sem publicação.
### Porquê investir — retirar o fundo, 26/10/2026
[SEM-FUNDO-EDITORIAL-20261026] Pedido expresso da Tempo: remover o fundo dos cinco parágrafos e deixar a apresentação normal. Retirados fundo azul e arredondamento do bloco, mantendo texto, disposição e espaçamentos compactos. Decisões anteriores conservadas no histórico. Sem alteração de dados, permissões ou cálculos. Verificação concluída: build e diff passaram; prévia isolada em 1440/390 px confirmou fundo transparente, geometria idêntica e preservação dos 17 parágrafos/15 links. Recibo em outputs/why-invest-no-background-20261026/. Alteração local, sem publicação.
## Conteúdo completo dos sectores no catálogo — 26/10/2026
[REPOSICAO-SECTORES-20261026-RESULTADO] Decisão expressa da Tempo: mostrar o conteúdo completo de cada sector na própria página /sectores. Inventário público de onze fichas: dez já tinham o bloco integral, Agronegócio conservava guia e 12 oportunidades, e Economia Azul omitia sectorEntry por depender do mapa editorial CONTENT (condição técnica desde 6a76aac8). cf003c86/3258a25f preservam as fichas; ec4b2f2a altera o índice visual. O catálogo de resumos não demonstra eliminação de dados. Implementado conjunto de dados/componente único para catálogo e ficha, dez secções completas com âncoras/retorno e cobertura comum de Economia Azul sem exigir artigo. Preservados publicação, especialização Fintech, páginas, originais e relações existentes; nenhuma escrita/publicação no activo. QA exclusivo sector_catalog_restore_20261026_test, 127.0.0.1:55441, cópia de QA local: dois testes/173 asserções passaram, com rollback e equivalência catálogo/ficha, exclusão de sectores/oportunidades não públicos e ausência de escritas por consulta. TypeScript, build e diff passaram; prévia isolada dos componentes reais com dados públicos/ponte QA da Economia Azul em 1440/390/320 px confirmou dez secções, acções, âncoras/retorno e ausência de transbordo. Recibos em outputs/sector-restoration-20261026/. Entrega local, sem disponibilização no destino; critérios globais permanecem no estado anterior.
Guia: abrir /sectores → escolher o sector no índice de cartões → consultar, na mesma página, preparação da ideia, cadastro na modalidade existente, guias públicos, âncoras/território e oportunidades. Abrir página do sector conserva a ficha individual; Voltar à lista de sectores regressa ao índice. Guias ou relações ausentes permanecem identificados, sem associações inferidas. Origem do âmbito: pedido expresso confirmado pela Tempo nesta conversa; partilha de componente e dados é escolha de implementação. Não há novo perfil, requisito de autenticação, migração ou cálculo. Os recibos de preservação/reposição da carteira de 23/10 foram consultados e mantêm a aceitação anterior, sem repetir ou declarar nova reposição dessa carteira.
### Fundo branco do texto editorial — 26/10/2026
[FUNDO-BRANCO-20261026] Pedido expresso da Tempo: fundo branco puro #FFFFFF no bloco dos cinco parágrafos de Porquê investir. Aplicado bg-white, conservando texto, geometria, espaços compactos e restantes entregas; sem perda funcional ou alteração de dados/permissões. Compilação e diff conferidos; alteração local sem publicação.
File diff suppressed because one or more lines are too long
@@ -13,9 +13,12 @@ export type EducationalArticle = {
export default function EducationalArticleCard({
article,
headingLevel = 3,
}: {
article: EducationalArticle;
headingLevel?: 3 | 4;
}) {
const Heading = headingLevel === 3 ? 'h3' : 'h4';
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800';
return (
@@ -34,11 +37,11 @@ export default function EducationalArticleCard({
<p className="text-sm font-medium text-emerald-800">
Guia sectorial
</p>
<h3 className="text-2xl leading-tight font-bold">
<Heading className="text-2xl leading-tight font-bold">
<Link href={article.href} className={focus}>
{article.title}
</Link>
</h3>
</Heading>
{article.summary && (
<p className="text-base leading-relaxed text-slate-700">
{article.summary}
+112
View File
@@ -0,0 +1,112 @@
import { Link } from '@inertiajs/react';
import SectorEntry, { type SectorEntryData } from '@/components/sector-entry';
export type SectorDetailData = {
sector: {
id: number;
name: string;
slug: string;
description: string | null;
short_description: string | null;
};
sectorEntry?: SectorEntryData | null;
opportunities?: {
title: string;
slug: string;
province: string | null;
summary: string | null;
}[];
};
const button =
'inline-block rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white disabled:opacity-50';
export default function SectorContent({
sector,
sectorEntry,
opportunities,
headingLevel = 2,
}: SectorDetailData & { headingLevel?: 2 | 3 }) {
const Heading = headingLevel === 2 ? 'h2' : 'h3';
return (
<div className="space-y-5">
<p className="whitespace-pre-line">
{sector.description ||
sector.short_description ||
'Descrição sectorial em preparação.'}
</p>
{!sectorEntry && (
<Link
className={button}
href={'/investimentos/iniciar?source=sector:' + sector.id}
>
Iniciar ou continuar uma ideia neste sector
</Link>
)}
{sectorEntry && (
<SectorEntry
headingLevel={headingLevel}
data={{
...sectorEntry,
start_href: sectorEntry.start_href
? '/investimentos/iniciar?source=sector:' +
sector.id
: null,
}}
/>
)}
<Heading className="text-xl font-bold">
Oportunidades no sector
</Heading>
{opportunities?.map((o) => (
<article key={o.slug} className="rounded border bg-white p-5">
<Link
className="text-lg font-bold underline"
href={`/oportunidades/${o.slug}`}
>
{o.title}
</Link>
<p>{o.province}</p>
<p>{o.summary}</p>
<ul aria-label="Localizações associadas">
{sectorEntry?.locations
.filter(
(l) => l.href === `/oportunidades/${o.slug}`,
)
.filter(
(l, i, rows) =>
rows.findIndex(
(other) =>
JSON.stringify(other.location) ===
JSON.stringify(l.location),
) === i,
)
.map((l, i) => (
<li key={i}>
{typeof l.location.name === 'string'
? l.location.name
: 'Consultar localização e fonte na oportunidade'}
</li>
))}
</ul>
</article>
))}
{!opportunities?.length && (
<p>Sem oportunidades publicadas neste sector.</p>
)}
<Link
className={button}
href={`/oportunidades?sector=${sector.slug}`}
>
Pesquisar todas as oportunidades do sector
</Link>
<p>
<Link href="/como-investir" className="underline">
Preparar o investimento
</Link>{' '}
·{' '}
<Link href="/apoio" className="underline">
Pedir apoio sectorial
</Link>
</p>
</div>
);
}
+18 -5
View File
@@ -24,7 +24,14 @@ export type SectorEntryData = {
specialization_href: string | null;
content_notice: string | null;
};
export default function SectorEntry({ data }: { data: SectorEntryData }) {
export default function SectorEntry({
data,
headingLevel = 2,
}: {
data: SectorEntryData;
headingLevel?: 2 | 3;
}) {
const Heading = headingLevel === 2 ? 'h2' : 'h3';
return (
<section
className="space-y-5"
@@ -64,7 +71,9 @@ 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.
</p>
<h2 className="text-xl font-bold">Descubra onde investir</h2>
<Heading className="text-xl font-bold">
Descubra onde investir
</Heading>
{data.content_notice && <p>{data.content_notice}</p>}
{!data.articles.length && (
<p>
@@ -74,7 +83,11 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
)}
<div className="grid gap-4 md:grid-cols-2">
{data.articles.map((a) => (
<EducationalArticleCard key={a.href} article={a} />
<EducationalArticleCard
key={a.href}
article={a}
headingLevel={headingLevel === 2 ? 3 : 4}
/>
))}
</div>
{data.specialization_href && (
@@ -87,9 +100,9 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
dependências.
</p>
)}
<h2 className="text-xl font-bold">
<Heading className="text-xl font-bold">
Projetos âncora, clusters e território
</h2>
</Heading>
{data.relations.length ? (
<ul>
{data.relations.map((r) => (
+47 -110
View File
@@ -12,7 +12,9 @@ import { ProvinceSelect } from '@/components/investment-territory';
import { SimulationForm } from '@/components/simulation-form';
import { InvestmentRoadmap } from '@/components/investment-roadmap';
import { JourneyDocumentResources } from '@/components/journey-document-resources';
import SectorEntry, { type SectorEntryData } from '@/components/sector-entry';
import SectorContent, {
type SectorDetailData,
} from '@/components/sector-content';
import CountrySelect, { type Country } from '@/components/country-select';
const input =
@@ -68,7 +70,8 @@ type Page<T> = {
total: number;
};
type Props = {
sectorEntry?: SectorEntryData;
sectorEntry?: SectorDetailData['sectorEntry'];
sectorDetails?: SectorDetailData[];
countries?: Country[];
section: string;
filters?: Record<string, string | string[]>;
@@ -80,19 +83,8 @@ type Props = {
url?: string;
specializations?: { id: number; name: string; url: string }[];
}[];
sector?: {
id: number;
name: string;
slug: string;
description: string;
short_description: string;
};
opportunities?: {
title: string;
slug: string;
province: string;
summary: string;
}[];
sector?: SectorDetailData['sector'];
opportunities?: SectorDetailData['opportunities'];
resources?: Page<Resource>;
categories?: string[];
topics?: string[];
@@ -511,8 +503,9 @@ export default function Services(p: Props) {
)}
{p.section === 'sectors' && (
<section
id="sectores-indice"
aria-label="Todos os sectores de investimento"
className="space-y-6"
className="scroll-mt-24 space-y-6"
>
<p className="text-slate-600">
Explore o potencial estratégico de cada sector em
@@ -525,9 +518,41 @@ export default function Services(p: Props) {
description: sector.short_description,
opportunitiesCount: sector.opportunities_count,
specializations: sector.specializations,
url: sector.url,
url: p.sectorDetails?.length
? `#sector-${sector.slug}`
: sector.url,
}))}
/>
{p.sectorDetails?.map((detail) => (
<section
id={`sector-${detail.sector.slug}`}
key={detail.sector.id}
aria-labelledby={`sector-heading-${detail.sector.slug}`}
className="scroll-mt-24 space-y-5 border-t border-slate-200 pt-6"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2
id={`sector-heading-${detail.sector.slug}`}
className="text-2xl font-bold"
>
{detail.sector.name}
</h2>
<Link
href={`/sectores/${detail.sector.slug}`}
className="inline-flex min-h-11 items-center text-sm font-semibold underline"
>
Abrir página do sector
</Link>
</div>
<SectorContent {...detail} headingLevel={3} />
<a
href="#sectores-indice"
className="inline-flex min-h-11 items-center text-sm font-semibold underline"
>
Voltar à lista de sectores
</a>
</section>
))}
{!p.sectors?.length && (
<p>Não existem sectores publicados.</p>
)}
@@ -539,99 +564,11 @@ export default function Services(p: Props) {
</section>
)}
{p.section === 'sector' && p.sector && (
<>
<p className="whitespace-pre-line">
{p.sector.description ||
p.sector.short_description ||
'Descrição sectorial em preparação.'}
</p>
{!p.sectorEntry && (
<Link
className={button}
href={
'/investimentos/iniciar?source=sector:' +
p.sector.id
}
>
Iniciar ou continuar uma ideia neste sector
</Link>
)}
{p.sectorEntry && (
<SectorEntry
data={{
...p.sectorEntry,
start_href: p.sectorEntry.start_href
? '/investimentos/iniciar?source=sector:' +
p.sector.id
: null,
}}
/>
)}
<h2 className="text-xl font-bold">
Oportunidades no sector
</h2>
{p.opportunities?.map((o) => (
<article
key={o.slug}
className="rounded border bg-white p-5"
>
<Link
className="text-lg font-bold underline"
href={`/oportunidades/${o.slug}`}
>
{o.title}
</Link>
<p>{o.province}</p>
<p>{o.summary}</p>
<ul aria-label="Localizações associadas">
{p.sectorEntry?.locations
.filter(
(l) =>
l.href ===
`/oportunidades/${o.slug}`,
)
.filter(
(l, i, rows) =>
rows.findIndex(
(other) =>
JSON.stringify(
other.location,
) ===
JSON.stringify(
l.location,
),
) === i,
)
.map((l, i) => (
<li key={i}>
{typeof l.location.name ===
'string'
? l.location.name
: 'Consultar localização e fonte na oportunidade'}
</li>
))}
</ul>
</article>
))}
{!p.opportunities?.length && (
<p>Sem oportunidades publicadas neste sector.</p>
)}
<Link
className={button}
href={`/oportunidades?sector=${p.sector.slug}`}
>
Pesquisar todas as oportunidades do sector
</Link>
<p>
<Link href="/como-investir" className="underline">
Preparar o investimento
</Link>{' '}
·{' '}
<Link href="/apoio" className="underline">
Pedir apoio sectorial
</Link>
</p>
</>
<SectorContent
sector={p.sector}
sectorEntry={p.sectorEntry}
opportunities={p.opportunities}
/>
)}
{['procedures', 'documents', 'legislation'].includes(
p.section,
+1 -1
View File
@@ -15,7 +15,7 @@ export default function WhyInvest() {
<div className="-mx-2 flow-root">
<article
aria-label="Investir em Moçambique"
className="rounded-lg bg-[#D0E2FF] p-2 lg:float-left lg:w-2/3"
className="bg-white p-2 lg:float-left lg:w-2/3"
>
<div className="space-y-3 text-base leading-6 text-slate-700">
<p>
@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\SectorInvestment;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class SectorCatalogueContentTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app = parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new \LogicException('Isolated QA required for sector catalogue restoration.');
}
return $app;
}
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
config(['portal.legacy_content_enabled' => false, 'portal.legacy_content_qa_enabled' => false,
'investor-path.enabled' => true, 'portal.profile_access_enabled' => true]);
}
private function sector(array $attributes = []): Sector
{
return Sector::create([...['name' => 'Sector sintético QA', 'slug' => 'qa-sector-'.Str::lower(Str::random(12)),
'short_description' => 'Exemplo isolado para a reposição.', 'is_active' => true, 'published_at' => now()->subDay()], ...$attributes]);
}
public function test_complete_catalogue_matches_public_sector_pages_including_sectors_without_guides(): void
{
$withoutGuide = $this->sector();
$this->assertArrayNotHasKey($withoutGuide->slug, SectorInvestment::CONTENT);
$before = [DB::table('desk_cases')->count(), DB::table('opportunities')->count(), DB::table('portal_resources')->count()];
$props = $this->get('/sectores')->assertOk()->inertiaProps();
$this->assertSame(array_column($props['sectors'], 'id'), array_column(array_column($props['sectorDetails'], 'sector'), 'id'));
foreach ($props['sectorDetails'] as $detail) {
$single = $this->get('/sectores/'.$detail['sector']['slug'])->assertOk()->inertiaProps();
$this->assertSame($single['sector'], $detail['sector']);
$this->assertSame($single['sectorEntry'], $detail['sectorEntry']);
$this->assertSame($single['opportunities'], $detail['opportunities']);
$this->assertIsArray($detail['sectorEntry']['articles']);
$this->assertIsArray($detail['sectorEntry']['relations']);
$this->assertIsArray($detail['sectorEntry']['locations']);
}
$entry = collect($props['sectorDetails'])->firstWhere('sector.id', $withoutGuide->id)['sectorEntry'];
$this->assertSame([], $entry['articles']);
$this->assertSame('/minhas-oportunidades/create?sector_id='.$withoutGuide->id, $entry['register_href']);
$this->assertNotNull($entry['start_href']);
$this->assertGuest();
$this->assertSame($before, [DB::table('desk_cases')->count(), DB::table('opportunities')->count(), DB::table('portal_resources')->count()]);
}
public function test_full_catalogue_preserves_publication_boundaries_and_private_tool_notices(): void
{
$public = $this->sector();
$unpublished = $this->sector(['published_at' => null]);
$inactive = $this->sector(['is_active' => false]);
$slugs = [];
foreach (['published', 'draft', 'future'] as $state) {
$slug = 'qa-sector-opportunity-'.Str::lower(Str::random(12));
$slugs[$state] = $slug;
Opportunity::create(['sector_id' => $public->id, 'reference' => $slug, 'title' => $state,
'slug' => $slug, 'summary' => 'Conteúdo sintético de teste.',
'status' => $state === 'draft' ? 'draft' : 'published', 'published_at' => $state === 'future' ? now()->addDay() : now()->subDay()]);
}
config(['investor-path.enabled' => false, 'portal.profile_access_enabled' => false]);
$props = $this->get('/sectores')->assertOk()->inertiaProps();
$ids = array_column(array_column($props['sectorDetails'], 'sector'), 'id');
$this->assertNotContains($unpublished->id, $ids);
$this->assertNotContains($inactive->id, $ids);
$detail = collect($props['sectorDetails'])->firstWhere('sector.id', $public->id);
$this->assertSame([$slugs['published']], array_column($detail['opportunities'], 'slug'));
$this->assertNull($detail['sectorEntry']['start_href']);
$this->assertNull($detail['sectorEntry']['register_href']);
$this->assertNotEmpty($detail['sectorEntry']['start_notice']);
$this->assertNotEmpty($detail['sectorEntry']['register_notice']);
$this->get('/sectores/'.$unpublished->slug)->assertNotFound();
$this->get('/sectores/'.$inactive->slug)->assertNotFound();
}
}