merge: preserve current main catalogue and restore accepted fiscal manual history
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user