Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad4160f040 | ||
|
|
af9261809c | ||
|
|
d8224ba84b | ||
|
|
088f2c2cf4 | ||
|
|
603a88e1d3 | ||
|
|
43cd0be7cb | ||
|
|
89a9c3d994 | ||
|
|
d242482042 | ||
|
|
ec4b2f2af2 | ||
|
|
e2b0fb4b2d |
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestmentComplements;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class InvestmentComplementController extends Controller
|
||||
{
|
||||
public function index(Request $request, InvestmentComplements $service)
|
||||
{
|
||||
$data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
|
||||
'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
|
||||
$items = [];
|
||||
$issues = [];
|
||||
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
|
||||
try {
|
||||
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
$status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404;
|
||||
if (!isset($data['contexts']) || $status !== 404) throw $e;
|
||||
$issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.'];
|
||||
}
|
||||
}
|
||||
return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class InvestmentDiscoveryController extends Controller
|
||||
$returnFilters = $request->query();
|
||||
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
|
||||
|
||||
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
|
||||
return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), '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)]);
|
||||
|
||||
@@ -131,7 +131,7 @@ class InvestmentPortfolioController extends Controller
|
||||
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
|
||||
: collect();
|
||||
|
||||
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
|
||||
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
||||
|
||||
@@ -11,6 +11,15 @@ use Inertia\Inertia;
|
||||
|
||||
final class InvestorContextController extends Controller
|
||||
{
|
||||
private function entryComplements(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40',
|
||||
'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']);
|
||||
if (empty($data['complement_origin'])) return [];
|
||||
return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
|
||||
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
|
||||
}
|
||||
|
||||
private function entryChoices(Request $r, InvestorContext $context): array
|
||||
{
|
||||
$data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1',
|
||||
@@ -21,6 +30,14 @@ final class InvestorContextController extends Controller
|
||||
$path = app(InvestmentGeography::class)->selection($data);
|
||||
$input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
|
||||
if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']];
|
||||
foreach ($this->entryComplements($r) as $complement) {
|
||||
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn($key) => str_starts_with($key, 'opportunity:'));
|
||||
foreach ($keys as $key) {
|
||||
if (in_array($key, array_column($input, 'source'), true)) continue;
|
||||
$input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []),
|
||||
...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])];
|
||||
}
|
||||
}
|
||||
return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
|
||||
}
|
||||
|
||||
@@ -84,7 +101,13 @@ final class InvestorContextController extends Controller
|
||||
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
|
||||
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
|
||||
}
|
||||
return Inertia::render('investor-path/context', ['entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
|
||||
$complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
|
||||
foreach ($this->entryComplements($r) as $entry) {
|
||||
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
|
||||
$entrySources[] = $tag;
|
||||
if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all();
|
||||
}
|
||||
return Inertia::render('investor-path/context', ['entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
|
||||
}
|
||||
|
||||
public function preview(Request $r, string $id, InvestorContext $context)
|
||||
@@ -103,7 +126,7 @@ final class InvestorContextController extends Controller
|
||||
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
|
||||
|
||||
$choices = $this->entryChoices($r, $context);
|
||||
return Inertia::render('investor-path/context-start', ['entryQuery' => $r->only(['from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
|
||||
return Inertia::render('investor-path/context-start', ['entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
|
||||
}
|
||||
|
||||
public function installation(Request $r, InvestorContextInstallation $install)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Resolve documented complements without creating opportunities or master relations. */
|
||||
final class InvestmentComplements
|
||||
{
|
||||
private const TERMS = [
|
||||
'irrigation' => ['Regadio', 'regadio|irrigação|irrigacao'],
|
||||
'storage' => ['Silos e armazenagem', 'silos?|armazenagem|armazenamento'],
|
||||
'cold' => ['Cadeia de frio', 'frio|refrigeração'],
|
||||
'processing' => ['Processamento', 'processamento|transformação|moagem'],
|
||||
'spinning' => ['Fiação', 'fiação|fiacao'],
|
||||
'logistics' => ['Logística', 'logística|logistica|transporte'],
|
||||
'certification' => ['Certificação', 'certificação|certificacao'],
|
||||
'training' => ['Formação', 'formação|formacao'],
|
||||
'finance' => ['Financiamento', 'financiamento|serviços financeiros'],
|
||||
];
|
||||
|
||||
public function options(string $origin, ?string $territory = null): array
|
||||
{
|
||||
[$originKind, $originId] = array_pad(explode(':', $origin, 2), 2, '');
|
||||
abort_unless(($originKind === 'portfolio' && Str::isUuid($originId)) || ($originKind === 'opportunity' && ctype_digit($originId)), 422, 'Origem complementar inválida.');
|
||||
$principal = null;
|
||||
$relationEvidence = [];
|
||||
if (str_starts_with($origin, 'portfolio:')) {
|
||||
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail(substr($origin, 10));
|
||||
$text = trim(($record->content['focus'] ?? '')."\n".($record->content['gaps'] ?? '')."\n".($record->content['description'] ?? ''));
|
||||
$reference = ['key' => $origin, 'title' => $record->title, 'href' => '/carteira/'.$record->id, 'version' => $record->version_no];
|
||||
$ids = app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record);
|
||||
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
|
||||
if ($record->$foreign) $ids = [...$ids, ...DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->pluck('opportunity_id')->all()];
|
||||
}
|
||||
$basis = $record->source;
|
||||
foreach (array_unique($ids) as $id) $relationEvidence[$id] = ['type' => 'portfolio', 'origin' => $origin, 'source' => $basis];
|
||||
$serviceOrigins = [['dossier', $record->id]];
|
||||
foreach (['project_id' => 'project', 'cluster_id' => 'cluster'] as $foreign => $type) {
|
||||
if ($record->$foreign) $serviceOrigins[] = [$type, $record->$foreign];
|
||||
}
|
||||
} else {
|
||||
$reference = app(InvestorContext::class)->reference($origin);
|
||||
abort_unless($reference['kind'] === 'opportunity', 422);
|
||||
$principal = Opportunity::published()->findOrFail($reference['id']);
|
||||
$text = $principal->summary;
|
||||
$basis = 'Resumo cadastrado da oportunidade '.$principal->reference;
|
||||
$projects = DB::table('opportunity_projects')->where('opportunity_id', $principal->id)->pluck('project_id');
|
||||
$links = DB::table('complementary_project_links')->whereIn('anchor_project_id', $projects)
|
||||
->whereNull('deleted_at')->where('data_classification', 'public')->get(['id', 'anchor_project_id', 'complementary_project_id', 'relation_type', 'version_no']);
|
||||
$targetProjects = DB::table('opportunity_projects')->whereIn('project_id', $links->pluck('complementary_project_id'))->get();
|
||||
$ids = [$principal->id, ...$targetProjects->pluck('opportunity_id')->all()];
|
||||
$relationEvidence[$principal->id] = ['type' => 'same_fiche', 'source' => $basis];
|
||||
foreach ($targetProjects as $targetProject) $relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project',
|
||||
'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn($link) => (array)$link)->all()];
|
||||
foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) $text .= "\n".$linked->summary;
|
||||
if (count($ids) > 1) $basis .= '; relações complementares públicas entre projectos';
|
||||
$serviceOrigins = [['investment', (string) $principal->id]];
|
||||
$classification = app(OpportunityClassification::class);
|
||||
$principalChains = collect($classification->publicRows($principal))->where('target_type', 'chain')->filter(fn($r) => !empty($r['source_reference']));
|
||||
$chains = $principalChains->pluck('target_id')->all();
|
||||
if ($chains) {
|
||||
Opportunity::published()->whereNot('id', $principal->id)->chunkById(100, function ($records) use (&$ids, &$relationEvidence, $chains, $principalChains, $classification) {
|
||||
foreach ($records as $candidate) {
|
||||
$related = collect($classification->publicRows($candidate))->where('target_type', 'chain')->filter(fn($r) => !empty($r['source_reference']));
|
||||
$shared = array_intersect($chains, $related->pluck('target_id')->all());
|
||||
if ($shared) {
|
||||
$ids[] = $candidate->id;
|
||||
$relationEvidence[$candidate->id] ??= ['type' => 'value_chain', 'principal' => $principalChains->whereIn('target_id', $shared)->values()->all(), 'target' => $related->whereIn('target_id', $shared)->values()->all()];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
$territoryReference = $territory ? app(InvestorContext::class)->reference('territory:'.$territory) : null;
|
||||
$candidates = Opportunity::published()->whereIn('id', array_unique($ids))->get();
|
||||
$items = [];
|
||||
foreach (self::TERMS as $code => [$label, $pattern]) {
|
||||
if (!preg_match('/\b(?:'.$pattern.')\b/iu', $text, $match)) continue;
|
||||
$offers = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
// A common corridor alone does not prove the component. Keep the supporting excerpt.
|
||||
if (!preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title."\n".$candidate->summary, $evidence)) continue;
|
||||
// Mentioning a need for funding/training in a project's summary is not an offer.
|
||||
if (in_array($code, ['certification', 'training', 'finance'], true) && !preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title)) continue;
|
||||
if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) continue;
|
||||
if ($territory && $candidate->primary_territory_id && !$this->compatible($territory, $candidate->primary_territory_id)) continue;
|
||||
$offers[] = ['key' => 'opportunity:'.$candidate->id, 'title' => $candidate->title, 'href' => '/oportunidades/'.$candidate->slug,
|
||||
'relationship' => $relationEvidence[$candidate->id] ?? null,
|
||||
'version' => $candidate->management_version, 'evidence' => $evidence[0], 'location' => ($principal && $principal->id === $candidate->id ? 'Componente referido na própria ficha. ' : '').($candidate->primary_territory_id ? app(InvestorContext::class)->reference('territory:'.$candidate->primary_territory_id)['title'] : 'Implantação por definir')];
|
||||
}
|
||||
// Services are concrete published offerings explicitly associated with this source.
|
||||
$services = [];
|
||||
if (in_array($code, ['certification', 'training', 'finance'], true) && Schema::hasTable('partnership_origins') && Schema::hasTable('partnership_interests')) {
|
||||
if ($serviceOrigins) {
|
||||
$services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id')
|
||||
->where(function ($q) use ($serviceOrigins) {
|
||||
foreach ($serviceOrigins as [$type, $id]) $q->orWhere(fn($q) => $q->where('o.type', $type)->where('o.target_id', $id));
|
||||
})->where('p.status', 'published')->where('p.kind', 'offering')
|
||||
->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training')
|
||||
->when($code !== 'finance', fn($q) => $q->where(fn($q) => $q->where('p.title', 'ilike', '%'.$match[0].'%')->orWhereRaw("p.payload->>'summary' ILIKE ?", ['%'.$match[0].'%'])))->distinct()->get(['p.id', 'p.title'])
|
||||
->filter(function ($p) use ($territory) {
|
||||
if (!$territory) return true;
|
||||
$locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id');
|
||||
return $locations->isEmpty() || $locations->contains(fn($id) => $this->compatible($territory, $id));
|
||||
})->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all();
|
||||
}
|
||||
}
|
||||
$excerpt = collect(explode("\n", $text))->first(fn($line) => preg_match('/\b(?:'.$pattern.')\b/iu', $line));
|
||||
$items[] = ['code' => $code, 'title' => $label, 'origin' => $reference, 'territory_id' => $territory, 'territory' => $territoryReference,
|
||||
'source' => $basis, 'excerpt' => $excerpt, 'source_sha256' => hash('sha256', $text), 'opportunities' => $offers, 'services' => $services];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function compatible(string $a, string $b): bool
|
||||
{
|
||||
if ($a === $b) return true;
|
||||
$geo = app(InvestmentGeography::class);
|
||||
return in_array($b, $geo->descendants($a), true) || in_array($a, $geo->descendants($b), true);
|
||||
}
|
||||
|
||||
public function resolve(array $input, array $previous = []): array
|
||||
{
|
||||
$resolved = [];
|
||||
foreach ($input as $choice) {
|
||||
$target = $choice['target'] ?? null;
|
||||
$old = collect($previous)->first(fn($r) => $r['origin']['key'] === $choice['origin'] && $r['code'] === $choice['code'] && ($r['target']['key'] ?? null) === $target && ($r['territory_id'] ?? null) === ($choice['territory_id'] ?? null));
|
||||
try {
|
||||
$item = collect($this->options($choice['origin'], $choice['territory_id'] ?? null))->firstWhere('code', $choice['code']);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
if (!$old) throw $e;
|
||||
$item = null;
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
if (!$old || $e->getStatusCode() !== 404) throw $e;
|
||||
$item = null;
|
||||
}
|
||||
$offer = $target && $item ? collect([...$item['opportunities'], ...$item['services']])->firstWhere('key', $target) : null;
|
||||
if ($old && (!$item || ($target && !$offer) || (!$target && ($item['opportunities'] || $item['services'])))) {
|
||||
$resolved[$old['key']] = [...$old, 'availability' => 'review_required'];
|
||||
continue;
|
||||
}
|
||||
abort_unless($item, 422, 'Complemento sem referência documentada neste investimento.');
|
||||
abort_if($target && !$offer, 422, 'A oferta complementar deixou de estar disponível neste contexto.');
|
||||
abort_if(!$target && ($item['opportunities'] || $item['services']), 422, 'Já existe uma oferta relacionada; seleccione a ficha correspondente.');
|
||||
$key = hash('sha256', $choice['origin'].'|'.$choice['code'].'|'.($target ?? 'idea').'|'.($choice['territory_id'] ?? ''));
|
||||
$resolved[$key] = ['key' => $key, 'origin' => $item['origin'], 'code' => $item['code'], 'title' => $item['title'],
|
||||
'kind' => $target ? (str_starts_with($target, 'opportunity:') ? 'opportunity' : 'service') : 'need',
|
||||
'target' => $offer, 'territory_id' => $item['territory_id'], 'territory' => $item['territory'], 'source' => $item['source'], 'excerpt' => $item['excerpt'], 'source_sha256' => $item['source_sha256']];
|
||||
}
|
||||
return array_values($resolved);
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,11 @@ final class InvestorContext
|
||||
$case=$this->desk->case($actor,$id,true);
|
||||
abort_if($case->closed_at,409);
|
||||
$sequence = (int)($input['draft_sequence'] ?? 0);
|
||||
$entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:100','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[];
|
||||
$entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:200','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[];
|
||||
unset($input['draft_sequence'],$input['entry_sources']);
|
||||
abort_unless($sequence > 0, 422);
|
||||
$data = $this->input($input);
|
||||
if (isset($data['complements'])) app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
|
||||
foreach ($data['selection'] as $slot=>$key) {
|
||||
if (!$key) continue;
|
||||
$ref=$this->reference($key);
|
||||
@@ -328,7 +329,7 @@ final class InvestorContext
|
||||
|
||||
private function input(array $input): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($input), ['opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.');
|
||||
abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.');
|
||||
|
||||
abort_if(! app(PrivateInvestmentPreparation::class)->available() && (! empty($input['document_links']) || array_intersect(array_keys(is_array($input['declarations'] ?? null) ? $input['declarations'] : []), PrivateInvestmentPreparation::FIELDS)), 409, 'Esta preparação ainda não está disponível neste ambiente. As respostas foram preservadas.');
|
||||
|
||||
@@ -341,6 +342,9 @@ final class InvestorContext
|
||||
}
|
||||
|
||||
return Validator::make($input, [...$inputRules,
|
||||
'complements' => 'sometimes|array|max:100', 'complements.*' => 'array:origin,code,target,territory_id',
|
||||
'complements.*.origin' => 'required|string|max:100', 'complements.*.code' => 'required|string|max:40',
|
||||
'complements.*.target' => 'nullable|string|max:100', 'complements.*.territory_id' => 'nullable|uuid',
|
||||
'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities,corridor_key,portfolio_origins',
|
||||
'opportunity_choices.*.portfolio_origins' => 'sometimes|array|max:100', 'opportunity_choices.*.portfolio_origins.*' => 'uuid',
|
||||
'opportunity_choices.*.corridor_key' => ['sometimes', 'nullable', Rule::in(array_keys(config('investment_corridors')))],
|
||||
@@ -361,6 +365,7 @@ final class InvestorContext
|
||||
abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão atual.');
|
||||
app(InvestorPath::class)->view($actor, $id);
|
||||
$old = $this->state($case);
|
||||
$complements = array_key_exists('complements', $data) ? app(InvestmentComplements::class)->resolve($data['complements'], $old['complements'] ?? []) : ($old['complements'] ?? []);
|
||||
$links = $old['preparation_documents'] ?? [];
|
||||
foreach ($data['document_links'] ?? [] as $key => $document) {
|
||||
if ($document) {
|
||||
@@ -388,6 +393,12 @@ final class InvestorContext
|
||||
abort(422, 'Actualize a selecção identificada de oportunidades; não substitua silenciosamente o conjunto por uma ficha.');
|
||||
}
|
||||
$selection = $old['selection'];
|
||||
foreach ($complements as $complement) {
|
||||
if (($complement['availability'] ?? '') === 'review_required') continue;
|
||||
foreach ([$complement['origin']['key'], $complement['target']['key'] ?? ''] as $key) {
|
||||
if (str_starts_with($key, 'opportunity:')) abort_unless(in_array($key, array_column(array_column($choices, 'opportunity'), 'key'), true), 422, 'Conserve a oportunidade escolhida ou retire explicitamente a sua relação complementar.');
|
||||
}
|
||||
}
|
||||
foreach ($data['selection'] as $slot => $key) {
|
||||
if ($key === null || $key === '') {
|
||||
unset($selection[$slot]);
|
||||
@@ -475,12 +486,13 @@ final class InvestorContext
|
||||
}
|
||||
}
|
||||
$changedFields = array_column($replaced, 'field');
|
||||
if ($complements !== ($old['complements'] ?? [])) $changed[] = 'complements';
|
||||
$choicesChanged = $choices !== ($old['opportunity_choices'] ?? []);
|
||||
$choiceInputsChanged = array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$choices) !== array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$old['opportunity_choices']??[]);
|
||||
if ($choiceInputsChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
|
||||
elseif ($choicesChanged) $changed[] = 'portfolio_origins';
|
||||
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
|
||||
$dependencyChanged = $choiceInputsChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
|
||||
$dependencyChanged = $choiceInputsChanged || in_array('complements', $changed, true) || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
|
||||
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
|
||||
$calculationInputsChanged = $choiceInputsChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]));
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
@@ -493,6 +505,7 @@ final class InvestorContext
|
||||
if ($privateConditionsChanged) {
|
||||
$steps = array_values(array_unique([...$steps, 'S02', 'S05', 'S06', 'S07']));
|
||||
}
|
||||
if (in_array('complements', $changed, true)) $steps = array_values(array_unique([...$steps, 'S02', 'S08']));
|
||||
$privatePreparation = null;
|
||||
if (app(PrivateInvestmentPreparation::class)->available()) {
|
||||
$docs = $this->preparationDocuments($id);
|
||||
@@ -500,7 +513,7 @@ final class InvestorContext
|
||||
$afterPreparation = app(PrivateInvestmentPreparation::class)->assess(['fields' => $fields, 'selection' => $selection, 'preparation_documents' => $links], $docs);
|
||||
$privatePreparation = ['before' => $beforePreparation, 'after' => $afterPreparation];
|
||||
}
|
||||
$plan = ['opportunity_choices' => $choices, 'case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
|
||||
$plan = ['complements' => $complements, 'opportunity_choices' => $choices, 'case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
|
||||
'preparation_documents' => $links, 'private_investment' => $privatePreparation,
|
||||
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $calculationInputsChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
|
||||
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'calculation_inputs_changed' => $calculationInputsChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
|
||||
@@ -545,8 +558,9 @@ final class InvestorContext
|
||||
$review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.'];
|
||||
}
|
||||
$context = ['opportunity_choices' => $plan['opportunity_choices'], 'preparation_documents' => $plan['preparation_documents'], 'preparation_snapshot' => $plan['private_investment']['after'] ?? $before['preparation_snapshot'] ?? null, 'revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
|
||||
'dependency_hash' => $this->dependencyHash([...($plan['opportunity_choices'] !== [] ? ['opportunity_choices' => $plan['opportunity_choices']] : []), 'selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
|
||||
'dependency_hash' => $this->dependencyHash([...($plan['opportunity_choices'] !== [] ? ['opportunity_choices' => $plan['opportunity_choices']] : []), ...($plan['complements'] ? ['complements' => $plan['complements']] : []), 'selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
|
||||
$context['requirements_assessment'] = $plan['requirements_assessment'];
|
||||
$context['complements'] = $plan['complements'];
|
||||
if ($plan['calculation_inputs_changed']) {
|
||||
foreach ($payload['investor_path']['results'] ?? [] as $i => $r) {
|
||||
$payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()];
|
||||
@@ -629,6 +643,7 @@ final class InvestorContext
|
||||
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
|
||||
'provenance' => $ctx['provenance'],
|
||||
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
|
||||
'complements' => $ctx['complements'] ?? [],
|
||||
'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
|
||||
'path' => $ctx['path_applicability'] ?? null,
|
||||
'land_preparation' => $ctx['land_preparation'] ?? null,
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: aplicar o anexo de identidade visual aos cinco cartões de Encontre o contexto do seu investimento.",
|
||||
"instructionSource": {
|
||||
"path": "C:\\Users\\jorge\\.codex\\attachments\\637b878f-1f58-490d-bd07-d5cd10ae2377\\Pasted text.txt",
|
||||
"sha256": "fcc24dcce4b5df3f10e29add48052808d48ddcf674d052287854e11910e01d69"
|
||||
},
|
||||
"source": {
|
||||
"type": "AI-generated photorealistic illustrative images",
|
||||
"tool": "image_gen, built-in mode",
|
||||
"skill": "C:/Users/jorge/.codex/skills/.system/imagegen/SKILL.md",
|
||||
"externalPhotographsUsed": false,
|
||||
"namedRealLocationsDepicted": false,
|
||||
"note": "Cenas ilustrativas plausíveis, sem atribuição a instalações, pessoas, instituições ou projectos reais."
|
||||
},
|
||||
"analysisAndPlan": "docs/investmoz/public-portal-sections.md#fotografias-nos-cartões-de-contexto-do-investimento--23102026",
|
||||
"filesChanged": [
|
||||
"resources/js/pages/PortalInvestidor.tsx",
|
||||
"resources/js/components/investment-context-cards.tsx",
|
||||
"tools/portal-context-cards-qa.mjs",
|
||||
"docs/investmoz/public-portal-sections.md",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
|
||||
"docs/investmoz/public-portal-context-images.json"
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"key": "clusters-agroindustria",
|
||||
"prompt": "Use case: photorealistic-natural. Asset type: an individual navigation card photograph for a professional investment portal. Generate ONE clean, realistic editorial photograph in horizontal 16:9 composition. Natural daylight, realistic restrained colours, excellent photographic detail, balanced uncluttered framing, one clear subject readable at a small thumbnail size. Contemporary setting plausible in Mozambique in southern Africa. This is a generic illustrative scene, NOT a depiction or claim about any named real facility, project, landmark or institution. No text, lettering, titles, captions, logos, brands, watermarks, flags, official emblems, maps, diagrams, graphical overlays, UI elements, collages, artificial colour filters, futuristic architecture or advertising poster treatment. Realistic people, hands, equipment and perspective; dignified candid activity instead of posed stock photography. Subject: a modest, well maintained agro-processing packing facility linking commercial agriculture to local industry. Wide three-quarter view into an airy open-sided processing shed with pale walls and a few orderly wooden or reusable produce crates, stainless processing/sorting benches and an unobtrusive commercial agricultural field visible beyond. Two Black southern African workers in clean practical workwear sorting harvested vegetables naturally, faces not prominent. Show production and transformation together in ONE genuine-looking continuous scene, no montage. Clear simple geometry, warm-neutral sunlight, subtle vegetation.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-f72e66b0-d788-4476-8b20-c39b22d5d6dc.png",
|
||||
"original": "outputs/investment-context-20261023/originals/clusters-agroindustria.png",
|
||||
"originalSha256": "75e04a694081ab757831fc9b37323949d09806e622c83d07588fc47c423c9033",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-context/clusters-agroindustria.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 116796,
|
||||
"sha256": "12494b31ab551cffb79f81c7d1519c0c4a2d4236e7f129bec1ce32251b39d847"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 42060,
|
||||
"sha256": "76e3152c99ed188f748542dcf0464787307dbdc1c6299e7264dfd1bd86ad75e3"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/clusters-agroindustria-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 9596,
|
||||
"sha256": "9f668da50f7d64955b31940d239b420ac18dbb76a8120c8bedab3f2c04710288"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "corredores-logisticos",
|
||||
"prompt": "Use case: photorealistic-natural. Asset type: an individual navigation card photograph for a professional investment portal. Generate ONE clean, realistic editorial photograph in horizontal 16:9 composition. Natural daylight, realistic restrained colours, excellent photographic detail, balanced uncluttered framing, one clear subject readable at a small thumbnail size. Contemporary setting plausible in Mozambique in southern Africa. This is a generic illustrative scene, NOT a depiction or claim about any named real facility, project, landmark or institution. No text, lettering, titles, captions, logos, brands, watermarks, flags, official emblems, maps, diagrams, graphical overlays, UI elements, collages, artificial colour filters, futuristic architecture or advertising poster treatment. Realistic people, hands, equipment and perspective; dignified candid activity instead of posed stock photography. Subject: a commercial freight terminal serving a southern African logistics corridor. Elevated medium-wide viewpoint showing an orderly short line of unbranded freight containers, one realistic container-handling crane and a freight truck on a paved lane, with a calm strip of water and low coastal industrial vegetation in the distance. Infrastructure of credible modest contemporary scale, generic and unidentifiable; do not reproduce or name Maputo, Beira, Nacala or any real port. Spatially coherent containers, road and handling equipment; clean clear subject with no excessive machinery.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-0363da23-f9cb-413b-bb64-024539085c4b.png",
|
||||
"original": "outputs/investment-context-20261023/originals/corredores-logisticos-v2.png",
|
||||
"originalSha256": "2151d1a59265c6f7151d18af6ef6ad78f9fc6e0cd58ac9cc5c64b50a25c5058e",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-context/corredores-logisticos-v2.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 85184,
|
||||
"sha256": "1939c26df297588b6c3e5ea08b700d4cda5d1183cf3e5faed06a1ca060d2475e"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 26222,
|
||||
"sha256": "06dc0e7126cb2c13eb3f9a98f6ee58f5d64f9d07c28620975f744f263744fe15"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/corredores-logisticos-v2-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 5334,
|
||||
"sha256": "f9fd6e6c3a2d4654df1a8fdb287e122f1889b5d318c104c4816ee393c793e242"
|
||||
}
|
||||
],
|
||||
"selectedSource": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-c2cff7da-d602-4191-9aca-380918a09619.png",
|
||||
"editPrompt": "Edit the provided photograph: REMOVE THE ENTIRE FOREGROUND TRUCK, including cab, wheels, long trailer and the red freight container on that trailer. The big truck at lower left/centre must be completely gone. Replace the removed truck's area with a continuous empty realistic paved road surface matching the surrounding pavement and road markings. Leave the stacked stationary freight containers along the quay, the water, the large port crane, the lifted container and workers. All remaining equipment and containers should have plain surfaces without readable lettering, logos, numbers or brands. No new truck, vehicle, symbol, text or overlay. Keep a single clean editorial photograph, natural lighting and horizontal 16:9 framing. Generic commercial port scene, not a named real facility."
|
||||
},
|
||||
{
|
||||
"key": "projectos-ancora",
|
||||
"prompt": "Use case: photorealistic-natural. Asset type: an individual navigation card photograph for a professional investment portal. Generate ONE clean, realistic editorial photograph in horizontal 16:9 composition. Natural daylight, realistic restrained colours, excellent photographic detail, balanced uncluttered framing, one clear subject readable at a small thumbnail size. Contemporary setting plausible in Mozambique in southern Africa. This is a generic illustrative scene, NOT a depiction or claim about any named real facility, project, landmark or institution. No text, lettering, titles, captions, logos, brands, watermarks, flags, official emblems, maps, diagrams, graphical overlays, UI elements, collages, artificial colour filters, futuristic architecture or advertising poster treatment. Realistic people, hands, equipment and perspective; dignified candid activity instead of posed stock photography. Subject: a small team of two Black southern African engineering professionals, one woman and one man, in realistic hard hats and restrained protective workwear, seen candidly from a three-quarter rear angle reviewing an unmarked folded technical plan at a utility-scale solar installation. Low rows of realistic blue solar panels and a simple low service building recede into a dry green southern African landscape. No readable drawing or numbers on the plan. Focus on credible engineering and investment project scale; clean composition, quiet professional atmosphere, no landmark or company identifiers.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-19fcd882-542b-43c9-81e7-593fd75083f6.png",
|
||||
"original": "outputs/investment-context-20261023/originals/projectos-ancora.png",
|
||||
"originalSha256": "f23cf6e64307a280f3aa58741454a23480ac28fcae01a0810adf134b783a3a74",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-context/projectos-ancora.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 77114,
|
||||
"sha256": "de0b59774246fee982761de59618b3e7707c3fe30f2b1bcbb6017d4dce53c4dc"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 29484,
|
||||
"sha256": "268e405112ed341fce3c9792c3caa47053984100cd4a204453e31bbe5db6e890"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/projectos-ancora-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 6750,
|
||||
"sha256": "db7e4b4382e09eb6b4f9f2a4603ccac4b4eacc53ba0f6776429e33dbf5131954"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "educacao-formacao",
|
||||
"prompt": "Use case: photorealistic-natural. Asset type: an individual navigation card photograph for a professional investment portal. Generate ONE clean, realistic editorial photograph in horizontal 16:9 composition. Natural daylight, realistic restrained colours, excellent photographic detail, balanced uncluttered framing, one clear subject readable at a small thumbnail size. Contemporary setting plausible in Mozambique in southern Africa. This is a generic illustrative scene, NOT a depiction or claim about any named real facility, project, landmark or institution. No text, lettering, titles, captions, logos, brands, watermarks, flags, official emblems, maps, diagrams, graphical overlays, UI elements, collages, artificial colour filters, futuristic architecture or advertising poster treatment. Realistic people, hands, equipment and perspective; dignified candid activity instead of posed stock photography. Subject: a candid technical training classroom with three Black southern African young adult students of different genders and one instructor working together at a practical electronics and computing workbench. Bright modest well maintained vocational classroom, natural window light, simple laboratory equipment and unbranded closed or angled laptops whose screens have no visible interface or text. Focus on two students engaged in a realistic small hands-on electronics exercise with normal anatomy and correct equipment proportions. Dignified contemporary clothing, natural concentration, professional skills and human capital investment, no posed smiles or stereotypes.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-d21314d7-c726-41ad-862c-3c8b64df6b53.png",
|
||||
"original": "outputs/investment-context-20261023/originals/educacao-formacao.png",
|
||||
"originalSha256": "e59ec599e8c64ab84195d0cb37871d3102df178a13275806ca3b7f98e6bfa895",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-context/educacao-formacao.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 68224,
|
||||
"sha256": "e3e5dbd292cac555a45bcbd94a0fa6b60e6b710debc0c827c68603f40bfb8a28"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 27384,
|
||||
"sha256": "d3b743fbba7a8cb40e45999ee8c345cbfc0332cf85f5c5c2d6a5cf1240a395e4"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/educacao-formacao-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 7156,
|
||||
"sha256": "0b2a12898043c57cf4354b54ebcba8adec8d41b3886bc574584ccc12af7b1e4c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "documentos-formularios",
|
||||
"prompt": "Use case: photorealistic-natural. Asset type: an individual navigation card photograph for a professional investment portal. Generate ONE clean, realistic editorial photograph in horizontal 16:9 composition. Natural daylight, realistic restrained colours, excellent photographic detail, balanced uncluttered framing, one clear subject readable at a small thumbnail size. Contemporary setting plausible in Mozambique in southern Africa. This is a generic illustrative scene, NOT a depiction or claim about any named real facility, project, landmark or institution. No text, lettering, titles, captions, logos, brands, watermarks, flags, official emblems, maps, diagrams, graphical overlays, UI elements, collages, artificial colour filters, futuristic architecture or advertising poster treatment. Realistic people, hands, equipment and perspective; dignified candid activity instead of posed stock photography. Subject: a close-medium editorial photograph of a tidy modern administrative office desk where a Black southern African professional in a plain light blue shirt is naturally reviewing organised business documentation. Frame hands, desk and partial torso, no prominent portrait. Neat neutral folders, a few sheets with only faint out-of-focus non-readable printing, a pen and an unbranded laptop angled away with no visible UI. Soft window light, white and pale neutral surfaces with subtle blue accents. Absolutely no readable personal data, invented official seals or highlighted fake text; realistic fingers and documents, calm minimal composition.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-f1c8e22e-6885-4578-ac7d-02692c5cc071.png",
|
||||
"original": "outputs/investment-context-20261023/originals/documentos-formularios.png",
|
||||
"originalSha256": "cfbd38a9cc9b9acf35f38220f5bd9c1ab1feafe411bc1d4bbdb3658de557e052",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-context/documentos-formularios.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 43002,
|
||||
"sha256": "70ddea3256e6a8d981b1fae5ee7b4de6c910d757137b79a59306c268c278402f"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 17996,
|
||||
"sha256": "436865c948acbb7b6e9e482bef8f5bd626c6960f2f155608e40dfcd0e76b84cb"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-context/documentos-formularios-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 4958,
|
||||
"sha256": "fad6a6ab825a82ec2541d2e0c6de9d85486e4bbca159b912f87bec9cc0aeeba5"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"primaryImagesBytesTotal": 390320,
|
||||
"responsive480ImagesBytesTotal": 143146,
|
||||
"implementation": {
|
||||
"gridColumns": [
|
||||
1,
|
||||
2,
|
||||
4
|
||||
],
|
||||
"aspectRatio": "16:9",
|
||||
"responsiveImages": true,
|
||||
"lazyLoading": true,
|
||||
"altLanguage": "pt",
|
||||
"fallback": "Derivado pequeno da própria imagem; se também falhar, estado acessível Imagem indisponível, com dimensão reservada e link mantido.",
|
||||
"globalCSSChanged": false,
|
||||
"businessLogicChanged": false,
|
||||
"databaseChanged": false,
|
||||
"heroChanged": false
|
||||
},
|
||||
"visualReview": "Cinco composições individuais distintas, cores naturais e luz editorial; sem títulos/colagens ou mapas. A composição de Corredores foi revista para remover o camião com marca. Capturas em computador e telemóvel inspeccionadas.",
|
||||
"verification": {
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"dpr": 1,
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"dpr": 2,
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"dpr": 1,
|
||||
"columns": 2,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"dpr": 1,
|
||||
"columns": 4,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"dpr": 2,
|
||||
"columns": 4,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"dpr": 1,
|
||||
"columns": 4,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"fail": "primary",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"fail": "all",
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"navigation": [
|
||||
"/clusters",
|
||||
"/corredores",
|
||||
"/projectos-ancora",
|
||||
"/educacao",
|
||||
"/documentos"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"script": "tools/portal-context-cards-qa.mjs",
|
||||
"screenshots": [
|
||||
"outputs/investment-context-20261023/cards-1024-dpr1.png",
|
||||
"outputs/investment-context-20261023/cards-1440-dpr2.png",
|
||||
"outputs/investment-context-20261023/cards-1920-dpr1.png",
|
||||
"outputs/investment-context-20261023/cards-320-dpr1.png",
|
||||
"outputs/investment-context-20261023/cards-390-dpr2.png",
|
||||
"outputs/investment-context-20261023/cards-768-dpr1.png",
|
||||
"outputs/investment-context-20261023/fallback-all.png",
|
||||
"outputs/investment-context-20261023/fallback-primary.png"
|
||||
],
|
||||
"types": {
|
||||
"command": "npm run types:check",
|
||||
"exitCode": 0
|
||||
},
|
||||
"lint": {
|
||||
"files": [
|
||||
"resources/js/components/investment-context-cards.tsx",
|
||||
"resources/js/pages/PortalInvestidor.tsx",
|
||||
"tools/portal-context-cards-qa.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"exitCode": 0,
|
||||
"pageAsset": "assets/PortalInvestidor-fh5VZanx.js",
|
||||
"log": "outputs/investment-context-20261023/build-local.log"
|
||||
}
|
||||
},
|
||||
"scope": "Aplicado no código e pacote locais. API e navegação simuladas na verificação visual; sem dados activos ou publicação externa.",
|
||||
"matrixReferences": [
|
||||
"MASTER-efd550fd-0104",
|
||||
"MASTER-55050f60-0001"
|
||||
],
|
||||
"acceptanceLimit": "Prova restrita à apresentação e navegação dos cinco cartões; não encerra critérios funcionais mais amplos da matriz.",
|
||||
"layoutRevision": {
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: título a vermelho e menos espaço em branco dentro dos cartões.",
|
||||
"changes": "Título no vermelho #ed1c24; removidas alturas mínimas de título e descrição; preenchimento 20→16 px, intervalo 8→6 px e entrelinhas 28/24→24/20 px. Mantidos imagens, conteúdos, links e alinhamento por linha da grelha.",
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"dpr": 1,
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"dpr": 2,
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios.webp",
|
||||
"width": 389,
|
||||
"height": 219
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"dpr": 1,
|
||||
"columns": 2,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 384,
|
||||
"height": 216
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"dpr": 1,
|
||||
"columns": 4,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 256,
|
||||
"height": 144
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"dpr": 2,
|
||||
"columns": 4,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"dpr": 1,
|
||||
"columns": 4,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/clusters-agroindustria-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/corredores-logisticos-v2-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/projectos-ancora-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/educacao-formacao-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-context/documentos-formularios-480.webp",
|
||||
"width": 266,
|
||||
"height": 149
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"fail": "primary",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"fail": "all",
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"navigation": [
|
||||
"/clusters",
|
||||
"/corredores",
|
||||
"/projectos-ancora",
|
||||
"/educacao",
|
||||
"/documentos"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"lintExitCode": 0,
|
||||
"buildExitCode": 0,
|
||||
"pageAsset": "assets/PortalInvestidor-CYCMhSzW.js",
|
||||
"screenshots": [
|
||||
"outputs/investment-context-compact-20261023/cards-1024-dpr1.png",
|
||||
"outputs/investment-context-compact-20261023/cards-1440-dpr2.png",
|
||||
"outputs/investment-context-compact-20261023/cards-1920-dpr1.png",
|
||||
"outputs/investment-context-compact-20261023/cards-320-dpr1.png",
|
||||
"outputs/investment-context-compact-20261023/cards-390-dpr2.png",
|
||||
"outputs/investment-context-compact-20261023/cards-768-dpr1.png"
|
||||
],
|
||||
"databaseChanged": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: adaptar hero, imagens e pesquisa da referência, preservando a informação e os comportamentos equivalentes do V2.",
|
||||
"sourceProject": "D:/Projects/Tempo_Investidor/investidor",
|
||||
"targetProject": "D:/Projects/Tempo_Investidor/Investidor-V2",
|
||||
"planAndAnalysis": "docs/investmoz/public-portal-sections.md#adequação-do-hero--23102026",
|
||||
"implementation": [
|
||||
"resources/js/components/portal-hero.tsx",
|
||||
"resources/js/pages/PortalInvestidor.tsx"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\investidor\\public\\images\\hero\\hero-1.png",
|
||||
"original": "public\\images\\hero\\hero-1.png",
|
||||
"sha256": "3c9246169025b9b1b4d1d10fb51c1f644123984bde56c6c2d9bf869d851024fc",
|
||||
"bytes": 2315768,
|
||||
"webpBytes": 94012,
|
||||
"mobileBytes": 38804
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\investidor\\public\\images\\hero\\hero-3.jpg",
|
||||
"original": "public\\images\\hero\\hero-3.jpg",
|
||||
"sha256": "91377213b6286aca7ee4af90860a7dc7a75fa08a165ef7b3192081db43f92dff",
|
||||
"bytes": 9875137,
|
||||
"webpBytes": 140342,
|
||||
"mobileBytes": 32998
|
||||
}
|
||||
],
|
||||
"desktopBytesTotal": 234354,
|
||||
"mobileBytesTotal": 71802,
|
||||
"originalBytesTotal": 12190905,
|
||||
"verification": {
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"reducedMotion": "no-preference",
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"requests": [
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=",
|
||||
"/oportunidades",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"browserScript": "tools/portal-hero-qa.mjs",
|
||||
"screenshots": [
|
||||
"outputs/portal-hero-20261023/after-1440-no-preference.png",
|
||||
"outputs/portal-hero-20261023/after-1440-reduce.png",
|
||||
"outputs/portal-hero-20261023/after-1440-second-image.png",
|
||||
"outputs/portal-hero-20261023/after-1920-reduce.png",
|
||||
"outputs/portal-hero-20261023/after-320-reduce.png",
|
||||
"outputs/portal-hero-20261023/after-390-reduce.png",
|
||||
"outputs/portal-hero-20261023/after-768-reduce.png",
|
||||
"outputs/portal-hero-20261023/before-1440-reduce.png",
|
||||
"outputs/portal-hero-20261023/before-390-reduce.png"
|
||||
],
|
||||
"additionalCases": [
|
||||
"pesquisa por Enter e botão",
|
||||
"ordem do teclado: campo → botão",
|
||||
"query acentuada e pontuação preservadas",
|
||||
"remoção de espaços exteriores",
|
||||
"pesquisa vazia abre catálogo",
|
||||
"CTA abre oportunidades",
|
||||
"API indisponível mantém indicadores indeterminados",
|
||||
"contagens zero preservadas",
|
||||
"duas imagens carregadas",
|
||||
"selecção manual",
|
||||
"pausa e retoma explícitas",
|
||||
"foco pausa rotação",
|
||||
"rotação automática após perda de foco",
|
||||
"movimento reduzido",
|
||||
"entrada documental e cabeçalho preservados"
|
||||
],
|
||||
"existingFrontendTests": {
|
||||
"command": "node --test tests/frontend/portal-api.test.mjs tests/frontend/portal-navigation.test.mjs",
|
||||
"passed": 20
|
||||
},
|
||||
"typecheck": {
|
||||
"command": "npm run types:check",
|
||||
"exitCode": 0
|
||||
},
|
||||
"lint": {
|
||||
"files": [
|
||||
"resources/js/components/portal-hero.tsx",
|
||||
"resources/js/pages/PortalInvestidor.tsx",
|
||||
"tools/portal-hero-qa.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
"qaBuild": {
|
||||
"command": "INVESTMOZ_QA_BUILD=1 npm run build",
|
||||
"exitCode": 0
|
||||
},
|
||||
"localBuild": {
|
||||
"command": "npm run build",
|
||||
"exitCode": 0,
|
||||
"pageAsset": "assets/PortalInvestidor-MsUeKoJQ.js"
|
||||
}
|
||||
},
|
||||
"matrix": {
|
||||
"file": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
|
||||
"referencesAdded": [
|
||||
"MASTER-9dee08d0-0016",
|
||||
"MASTER-efd550fd-0104",
|
||||
"MASTER-55050f60-0001"
|
||||
],
|
||||
"rowCount": 3422,
|
||||
"criteriaAndStatesPreserved": true
|
||||
},
|
||||
"scope": {
|
||||
"appliedToLocalCodeAndBuild": true,
|
||||
"activeDatabaseChanged": false,
|
||||
"migrationsRun": false,
|
||||
"externalDeploymentPerformed": false,
|
||||
"backendSearchReimplemented": false
|
||||
},
|
||||
"limits": "Verificação visual com dados sintéticos; pesquisa continua limitada ao catálogo de oportunidades. Os critérios mais amplos da matriz permanecem no estado anterior.",
|
||||
"latestRevision": {
|
||||
"id": "hero-no-controls-20261023",
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: remover do hero as duas bolinhas e o botão de pausa das imagens.",
|
||||
"implementation": "Removidos grupo de controlos, ícones Pause/Play e estado de pausa manual. Preservadas troca automática, escrita dinâmica, pesquisa e preferência de movimento reduzido.",
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"reducedMotion": "no-preference",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"reducedMotion": "no-preference",
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"requests": [
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=",
|
||||
"/oportunidades",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=",
|
||||
"/oportunidades",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"screenshots": [
|
||||
"outputs/portal-hero-no-controls-20261023/after-1440-no-preference.png",
|
||||
"outputs/portal-hero-no-controls-20261023/after-1440-reduce.png",
|
||||
"outputs/portal-hero-no-controls-20261023/after-1920-reduce.png",
|
||||
"outputs/portal-hero-no-controls-20261023/after-320-reduce.png",
|
||||
"outputs/portal-hero-no-controls-20261023/after-390-no-preference.png",
|
||||
"outputs/portal-hero-no-controls-20261023/after-390-reduce.png",
|
||||
"outputs/portal-hero-no-controls-20261023/after-768-reduce.png",
|
||||
"outputs/portal-hero-no-controls-20261023/second-image-1440.png",
|
||||
"outputs/portal-hero-no-controls-20261023/second-image-390.png",
|
||||
"outputs/portal-hero-no-controls-20261023/typed-1440.png",
|
||||
"outputs/portal-hero-no-controls-20261023/typed-390.png"
|
||||
],
|
||||
"checks": {
|
||||
"lintExitCode": 0,
|
||||
"buildExitCode": 0,
|
||||
"pageAsset": "assets/PortalInvestidor-T0B6aoV_.js"
|
||||
},
|
||||
"scope": {
|
||||
"appliedLocally": true,
|
||||
"databaseAccess": false,
|
||||
"externalDeployment": false
|
||||
}
|
||||
},
|
||||
"revisionHistory": [
|
||||
{
|
||||
"id": "hero-typing-20261023",
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: título e frase dinâmicos como na referência; remover Termos de uso do hero.",
|
||||
"plan": [
|
||||
"Analisar a sequência de escrita da referência.",
|
||||
"Animar o título e depois a frase, mantendo os textos do V2 e reservando a dimensão final.",
|
||||
"Reiniciar a escrita na troca de imagem e integrar pausa/retoma e preferência de movimento reduzido.",
|
||||
"Remover o link e o ícone jurídico do hero.",
|
||||
"Demonstrar comportamento em computador e telemóvel e compilar o pacote local."
|
||||
],
|
||||
"files": [
|
||||
"resources/js/components/portal-hero.tsx",
|
||||
"resources/css/app.css",
|
||||
"tools/portal-hero-qa.mjs"
|
||||
],
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"reducedMotion": "reduce",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"reducedMotion": "no-preference",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"reducedMotion": "no-preference",
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"requests": [
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=",
|
||||
"/oportunidades",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique",
|
||||
"/oportunidades?q=",
|
||||
"/oportunidades",
|
||||
"/oportunidades?q=energia%20%26%20Nampula%20%2F%20Mo%C3%A7ambique"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"screenshots": [
|
||||
"outputs/portal-hero-typing-20261023/after-1440-no-preference.png",
|
||||
"outputs/portal-hero-typing-20261023/after-1440-reduce.png",
|
||||
"outputs/portal-hero-typing-20261023/after-1440-second-image.png",
|
||||
"outputs/portal-hero-typing-20261023/after-1920-reduce.png",
|
||||
"outputs/portal-hero-typing-20261023/after-320-reduce.png",
|
||||
"outputs/portal-hero-typing-20261023/after-390-no-preference.png",
|
||||
"outputs/portal-hero-typing-20261023/after-390-reduce.png",
|
||||
"outputs/portal-hero-typing-20261023/after-768-reduce.png",
|
||||
"outputs/portal-hero-typing-20261023/typed-1440.png",
|
||||
"outputs/portal-hero-typing-20261023/typed-390.png",
|
||||
"outputs/portal-hero-typing-20261023/typing-1440.png",
|
||||
"outputs/portal-hero-typing-20261023/typing-390.png"
|
||||
],
|
||||
"demonstrated": [
|
||||
"escrita progressiva",
|
||||
"título antes da frase",
|
||||
"texto completo no modo de movimento reduzido",
|
||||
"nome acessível completo durante a escrita",
|
||||
"pesquisa sem deslocamento durante a escrita",
|
||||
"reinício na selecção manual e automática de imagem",
|
||||
"pausa e retoma da escrita",
|
||||
"cursor com pausa",
|
||||
"Termos de uso ausente do hero",
|
||||
"pesquisa, teclado, CTA, API indisponível e contagens zero preservados"
|
||||
],
|
||||
"checks": {
|
||||
"types": {
|
||||
"command": "npm run types:check",
|
||||
"exitCode": 0
|
||||
},
|
||||
"lint": {
|
||||
"command": "vp lint resources/js/components/portal-hero.tsx tools/portal-hero-qa.mjs",
|
||||
"exitCode": 0
|
||||
},
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"exitCode": 0,
|
||||
"pageAsset": "assets/PortalInvestidor-BbVHeTeI.js",
|
||||
"log": "outputs/portal-hero-typing-20261023/build-local.log"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"appliedLocally": true,
|
||||
"databaseAccess": false,
|
||||
"externalDeployment": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,3 +48,91 @@ Edge headless: 11 páginas públicas/autenticação verificadas a 390×844, com
|
||||
Página inicial também verificada a 1440×1000. Cenário fiscal exercitado no navegador: base 1.000 × taxa 10% × (1 − redução 50%) = 50. Resultados em `public-portal-browser-validation.json`.
|
||||
|
||||
Antes da migration local foi criado e restaurado o backup de 74 tabelas em `storage/app/private/portal-services-backup-20260927-090641/`. O backup completo anterior permanece em `C:/laragon/backups/portal-investidor-20260927-101224/` e representa o estado anterior a esta entrega.
|
||||
|
||||
## Adequação do hero — 23/10/2026
|
||||
|
||||
Origem: pedido expresso da Tempo nesta sessão para aproveitar o hero, as imagens e a barra de pesquisa de `D:/Projects/Tempo_Investidor/investidor`, usando a informação existente no V2. Aplicam-se as orientações permanentes do `AGENTS.md`, incluindo a distinção entre decisão da Tempo e escolha técnica, a preservação de dados e a separação de QA e activo.
|
||||
|
||||
Análise prévia: a página de entrada real do V2 é `PortalInvestidor.tsx`, servida por `PortalServicesController::home`. A referência é `domains/investor-portal/components/homepage/homepage-hero.tsx`, montada em `InstitutionalHomepage`. A referência usa Inertia 2, Framer Motion, tradução e navegação diferentes; o V2 usa Inertia 3 e pesquisa de oportunidades. Copiar essas dependências e `/pesquisa` ou `/aviso-legal` directamente criaria incompatibilidades e destinos inexistentes.
|
||||
|
||||
Plano executado:
|
||||
|
||||
1. Registar a apresentação anterior em computador e telemóvel, com os componentes reais e dados sintéticos.
|
||||
2. Copiar os dois originais efectivamente usados pela referência (`hero-1.png` e `hero-3.jpg`), conferir SHA-256 e gerar derivados WebP para computador e telemóvel. `hero-2.png` não faz parte da rotação da referência.
|
||||
3. Extrair `components/portal-hero.tsx` e aplicar fotografias em rotação, texto centrado e pesquisa branca com botão vermelho. Preservar os textos do V2, contagens da API, sectores, documentos, apoio, cabeçalho e rodapé.
|
||||
4. Manter a pesquisa de oportunidades em `/oportunidades?q=`, incluindo pesquisa vazia e caracteres acentuados. Conservar “Consultar oportunidades” e “Planear finanças”. A primeira iteração acrescentou o acesso jurídico `/termos-de-uso` ao hero; foi removido na revisão expressamente pedida pela Tempo abaixo.
|
||||
5. Verificar apresentação, teclado, imagens, rotação, pausa, movimento reduzido, falha da API, contagens zero, TypeScript, lint e compilação. Compilar primeiro o pacote QA e depois os assets locais em `public/build`.
|
||||
|
||||
Escolhas técnicas desta adaptação: React e transições CSS existentes, sem novas dependências; rotação de oito segundos, pausa por controlo explícito, foco, ponteiro ou separador oculto; apresentação estática quando o utilizador pede movimento reduzido. A primeira iteração apresentou o texto estático; a revisão abaixo introduz a escrita dinâmica pedida pela Tempo. O campo indica pesquisa de oportunidades e conserva exemplos que o catálogo suporta; esta alteração não implementa uma pesquisa global de legislação/licenciamento.
|
||||
|
||||
Evidência reproduzível: `tools/portal-hero-qa.mjs`, com Vite local e Playwright configurável por `HERO_QA_PLAYWRIGHT`/`HERO_QA_CHROMIUM`. Fixtures e capturas em `outputs/portal-hero-20261023/`; síntese persistente em `public-portal-hero-validation.json`. Os testes de interface interceptam a API e a navegação Inertia, sem acesso a Laravel, base de dados ou serviços externos. A compilação local aplica os assets desta pasta; não representa publicação noutro servidor.
|
||||
|
||||
Referências na matriz existente: `MASTER-efd550fd-0104` (composição da página), `MASTER-9dee08d0-0016` (contexto da pesquisa) e `MASTER-55050f60-0001` (entrada documental preservada). Esta prova cobre a adaptação visual e os percursos exercitados; não encerra os critérios funcionais mais amplos dessas linhas.
|
||||
|
||||
### Revisão: escrita dinâmica e remoção do acesso jurídico no hero
|
||||
|
||||
Decisão expressa da Tempo na continuação da sessão de 23/10/2026: animar “INVISTA EM MOÇAMBIQUE” e “Transforme informação em decisões e ideias em negócios reais.” como na página de referência, e remover “Termos de uso” do hero. A anterior apresentação estática e a inclusão desse link eram escolhas de implementação, agora substituídas pelo pedido do proprietário.
|
||||
|
||||
Análise e plano executado: reproduzir a sequência da referência (espera inicial de 250 ms, título a 50 ms por carácter, intervalo de 150 ms e frase a 28 ms por carácter); reiniciar ao trocar de imagem; acrescentar o cursor intermitente; reservar a dimensão final de ambas as linhas para manter a pesquisa imóvel; remover o link e o ícone jurídico do hero; verificar em computador e telemóvel e compilar o pacote local.
|
||||
|
||||
Implementação em `portal-hero.tsx`, com React existente e CSS do cursor em `app.css`, sem dependências novas. A pausa explícita suspende também a escrita, retomando do mesmo ponto. Quando o separador fica oculto a escrita pára; a preferência de movimento reduzido apresenta os dois textos completos. Leitores de ecrã recebem o texto completo uma única vez, independentemente do número de caracteres visíveis. Os temporizadores são limpos ao mudar de imagem ou desmontar o componente.
|
||||
|
||||
O script `tools/portal-hero-qa.mjs` demonstra a escrita progressiva, a ordem título→frase, o reinício manual/automático, a pausa/retoma, a posição estável da pesquisa, o modo sem movimento e a ausência de “Termos de uso” no hero. As capturas e resultados desta revisão ficam em `outputs/portal-hero-typing-20261023/`, preservando a evidência anterior. Síntese e compilação final registadas na revisão de `public-portal-hero-validation.json`; referência acrescentada à linha existente `MASTER-efd550fd-0104`, sem encerrar o seu critério mais amplo.
|
||||
|
||||
### Revisão: remoção dos controlos visíveis das imagens
|
||||
|
||||
Pedido expresso da Tempo na continuação de 23/10/2026: remover do hero o grupo com o botão de pausa e as duas bolinhas de selecção de imagem. Removidos o grupo, os ícones e o estado de pausa manual. A troca automática de imagens e a escrita dinâmica permanecem; conservam-se a pausa da rotação por foco/ponteiro, a suspensão em separador oculto e a preferência de movimento reduzido. Esta decisão substitui os controlos visíveis introduzidos como escolha de implementação nas revisões anteriores.
|
||||
|
||||
O script existente de interface foi adaptado para demonstrar a ausência dos controlos e a continuação da rotação automática, escrita e pesquisa, com capturas em `outputs/portal-hero-no-controls-20261023/`. Evidência e pacote local registados em `public-portal-hero-validation.json`, mantendo o histórico das revisões e os estados dos requisitos da matriz.
|
||||
|
||||
## Fotografias nas etapas de Como Investir — 23/10/2026
|
||||
|
||||
Origem: pedido expresso da Tempo para analisar e ilustrar exclusivamente os cartões “1. Explorar”, “2. Preparar” e “3. Acompanhar”. Aplicam-se as orientações permanentes de `AGENTS.md` de 12/10 e 22/10/2026; componentes e optimização de recursos são escolhas técnicas, sem novas regras operacionais.
|
||||
|
||||
Análise e plano: os cartões existentes são âncoras estáticas na home React/Inertia, numa grelha de três colunas a partir de 768 px. Explorar liga a `/oportunidades` e representa a identificação de sector, território e oportunidade; Preparar liga a `/como-investir` e representa percurso/documentação; Acompanhar liga a `/balcao-virtual` e representa envio/consulta de pedido e protocolo. Os recursos locais existentes ilustram outras secções; preparar três fotografias distintas para evitar repetição. Gerar cenas editoriais plausíveis com `imagegen`, rever composição, guardar originais e versões WebP locais, integrar imagens 16:9 com dimensões reservadas e carregamento diferido, manter textos/destinos/título vermelho, verificar comportamento responsivo, navegação e falhas de imagem, compilar. Âmbito exclusivamente visual, sem alterações em dados, autenticação, apoio ou rodapé. Referência documental na linha existente `MASTER-efd550fd-0104`, sem criar outra matriz nem encerrar o critério mais amplo.
|
||||
|
||||
Implementação: `components/investment-steps.tsx` reúne os três cartões, preservando títulos, descrições e âncoras. Fotografias individuais geradas com a ferramenta integrada `image_gen`, sem local ou projecto real atribuído: avaliação de oportunidade num parque empresarial genérico, preparação documental numa reunião e consulta de pedido no computador. Originais em `outputs/investment-steps-20261023/originals/`; recursos locais em `public/images/investment-steps/` com WebP 960×540, 480×270 e alternativa própria 240×135. Os cartões usam `srcset`, `sizes`, `loading="lazy"`, dimensões reservadas, corte 16:9, alt em português, foco visível e texto compacto com 16 px de preenchimento. Se também falhar a alternativa pequena, o cartão mantém a área reservada, descrição acessível e destino utilizável.
|
||||
|
||||
Verificação desta secção: componentes reais com API sintética e sem base de dados, seis larguras de 320 a 1920 px, duas densidades de ecrã, três imagens distintas, texto/destinos conservados, navegação por teclado, ausência de transbordo e deslocamento no carregamento, falhas dos recursos principais e de todos os recursos. Oito cenários sem erros JavaScript. Fotografias, prompts, hashes, capturas, relatório de browser e compilação documentados em `public-portal-steps-images.json` e `outputs/investment-steps-20261023/`. Ajuste restrito a esta secção; apoio, acesso à gestão e rodapé mantidos.
|
||||
|
||||
## Fotografias nos cartões de contexto do investimento — 23/10/2026
|
||||
|
||||
Origem: pedido expresso da Tempo para aplicar o anexo “Melhorar as imagens e a identidade visual do Portal do Investidor de Moçambique” exclusivamente à secção “Encontre o contexto do seu investimento”. O anexo define estética fotográfica editorial, imagens individuais contextualizadas, recursos locais, acessibilidade, desempenho e preservação dos cinco destinos e conteúdos. Aplicam-se as orientações permanentes de `AGENTS.md` de 12/10 e 22/10/2026; os pormenores de implementação abaixo são escolhas técnicas.
|
||||
|
||||
Análise prévia: Laravel/Inertia 3 e React, com Tailwind existente; os cinco cartões estavam embutidos em `pages/PortalInvestidor.tsx`. `EducationalArticleCard` destina-se a artigos com acções e metadados diferentes, pelo que não foi alterado. A secção foi extraída para `components/investment-context-cards.tsx`, mantendo o título, descrições, âncoras e grelha de uma/duas/quatro colunas. Não foram alteradas as páginas de destino, dados, APIs ou estilos globais.
|
||||
|
||||
Foram geradas cinco imagens distintas com a ferramenta integrada `image_gen`, segundo a skill `imagegen`: agro-indústria, terminal logístico, acompanhamento de instalação solar, formação técnica e análise documental. São imagens fotográficas ilustrativas geradas, sem localização ou instituição real atribuída, e não fotografias documentais de projectos cadastrados. A imagem do terminal foi revista para remover o camião com marca visível. Não foram usadas fotografias externas nem URLs de serviços de imagens. Originais seleccionados, prompts e hashes estão registados no manifesto `public-portal-context-images.json`; os originais foram preservados dentro do workspace.
|
||||
|
||||
Recursos finais em `public/images/investment-context/`: `clusters-agroindustria`, `corredores-logisticos-v2`, `projectos-ancora`, `educacao-formacao` e `documentos-formularios`, em WebP 960×540, com variantes 480×270 e alternativas 240×135. As imagens mantêm 16:9, `object-cover`, textos alternativos em português, dimensões reservadas, `srcset`, `sizes`, `loading="lazy"` e descodificação assíncrona. Cada cartão conserva o seu título e descrição; o foco usa o azul e os tons neutros pedidos no anexo.
|
||||
|
||||
Se um recurso principal falhar, o cartão usa a sua própria variante pequena, evitando repetir a fotografia de outra secção. Se também essa falhar, apresenta “Imagem indisponível” no mesmo espaço reservado, com descrição acessível e link operacional. Este é apenas um estado de erro; os cinco cartões usam imagens fotográficas na apresentação normal.
|
||||
|
||||
Verificação reproduzível: `tools/portal-context-cards-qa.mjs`, com o portal e cabeçalho reais, API sintética e acesso exclusivo aos recursos locais. Exercita seis larguras de ecrã, ecrãs de alta densidade, os cinco destinos por teclado, imagens carregadas e distintas, alinhamento, dimensões, ausência de deslocamento no carregamento e dois cenários de falha. Capturas e resultados em `outputs/investment-context-20261023/`; síntese, fontes/prompts e compilação em `public-portal-context-images.json`. Referências acrescentadas às linhas existentes `MASTER-efd550fd-0104` e `MASTER-55050f60-0001`, sem encerrar os respectivos critérios mais amplos.
|
||||
|
||||
Revisão expressa da Tempo de 23/10/2026: título da secção no vermelho da identidade existente (`#ed1c24`) e cartões mais compactos. Removidas as alturas mínimas de 56 e 72 px do título/descrição; reduzidos o preenchimento de 20 para 16 px, o intervalo de 8 para 6 px e as entrelinhas. As alturas passam a acompanhar o conteúdo, com alinhamento por linha da grelha, conservando imagens, textos e destinos. A reserva das dimensões das imagens mantém-se. Evidência desta revisão em `outputs/investment-context-compact-20261023/` e no campo `layoutRevision` do manifesto existente.
|
||||
|
||||
## Sectores da home e catálogo completo — 23/10/2026
|
||||
|
||||
Origem: pedido expresso da Tempo para adaptar a estrutura de “Áreas de Investimento” do projecto `D:/Projects/Tempo_Investidor/tempo-dashboard`, incluindo cartões, botão para mais sectores e respectiva página, e uniformizar a vermelho os títulos das secções da home. Aplicadas as orientações permanentes `AGENTS.md` de 12/10 e 22/10/2026: as decisões de produto são da Tempo; as escolhas técnicas abaixo não criam requisitos organizacionais.
|
||||
|
||||
Análise prévia: a referência usa `components/investor-portal/homepage/homepage-sectors.tsx` e `pages/investor-portal/sectors/index.tsx`. Mostra cinco cartões com fotografia e título sobreposto, resumo, “Saiba mais” e ligação central arredondada para o catálogo. O V2 já dispunha de `/sectores`, fichas `/sectores/{slug}`, especialização Fintech e dados de `SectorOrganization`. Foram reutilizados esses contratos e percursos, sem importar Framer Motion, traduções ou rotas exclusivas da referência.
|
||||
|
||||
Implementação: `components/public-sector-cards.tsx` partilha os cartões entre a home e o catálogo. A home mostra os primeiros cinco sectores, na ordem recebida da API, com “Ler mais” para a ficha existente e “Ver mais sectores” para `/sectores`. A página existente apresenta todos os sectores publicados, na mesma estrutura visual, e “Voltar ao início”. Mantêm-se nomes, descrições, contagens, valores zero, especializações e URLs canónicos recebidos. Descrições ausentes não são preenchidas com dados da referência. Fintech permanece ligado à ficha própria, sem âncoras HTML aninhadas.
|
||||
|
||||
Foram inventariados os onze JPG disponíveis em `storage/app/public/investor-portal/sectors` na referência, copiados os originais para evidência e preparados derivados locais WebP 960×540/480×270 em `public/images/sectors/`. O mapeamento visual por slug é de apresentação e não altera a taxonomia. Para Imobiliário e sectores sem imagem específica usa-se a vista empresarial genérica disponível na referência; para Bens de Consumo e Franquias usa-se a imagem de actividade industrial. A variante genérica também serve como alternativa em caso de erro; se ambas falharem, o título e as ligações continuam visíveis num fundo neutro. Não se atribui aos ficheiros uma origem oficial, licença externa ou localização documental não comprovada.
|
||||
|
||||
Os seis títulos de secção da home — contexto do investimento, sectores, Como Investir, oportunidades em destaque, documentos essenciais e apoio — usam `HOME_SECTION_HEADING` em `lib/portal-presentation.ts`: vermelho `#ed1c24`, 24 px, entrelinha de 32 px e peso 700. O cartão de Apoio passou a superfície clara para manter contraste com o título vermelho. O título principal animado do hero mantém a sua hierarquia própria. `PORTAL_MORE_LINK` centraliza o botão arredondado, contorno vermelho e estado de foco, seguindo a referência.
|
||||
|
||||
Verificação: `tools/portal-home-sectors-qa.mjs` carrega os componentes reais com dez sectores sintéticos, descrições nulas e especialização Fintech, sem base de dados. Exercita seis larguras, prévia de cinco/cartálogo de dez, estilos dos seis títulos, percurso por teclado, dez fichas, Fintech, regresso à home, catálogo vazio e dois cenários de indisponibilidade de imagens. Capturas, inventário, hashes e resultados em `outputs/home-sectors-20261023/`; resumo persistente em `public-portal-sectors-validation.json`. Referências na matriz existente: `MASTER-efd550fd-0104` e `MASTER-9f5719c8-0077`. Esta adaptação não encerra critérios mais amplos nem altera dados, permissões, regras operacionais ou publicação noutro servidor.
|
||||
|
||||
Pedido expresso da Tempo, 23/10/2026 — botão “Contactar Apoio ao Investidor”: em `layouts/portal-navigation.tsx`, a faixa de largura total foi substituída por um botão centrado, com largura ajustada ao texto, altura mínima de 44 px, preenchimento reduzido, sombra discreta e vermelho `#ed1c24`, conservando `/apoio` e o foco visível. Verificado a 320, 390 e 1440 px: cerca de 223×44 px, sem transbordo ou erros JavaScript. Evidência em `outputs/support-button-20261023/qa-results.json` e capturas; compilação local no mesmo directório. Ajuste de apresentação por decisão do proprietário, sem nova regra operacional nem alteração de dados.
|
||||
|
||||
Revisão anterior de 23/10/2026, superada pela correcção de âmbito abaixo: o Codex interpretou o pedido de abreviação como aplicável à zona inferior inteira e alterou indevidamente o botão de apoio e o rodapé partilhado. Essa interpretação não foi uma decisão da Tempo. A revisão removeu o bloco interno, abreviou o botão para “Apoio” e recolheu contactos/ligações em “Informações” num rodapé claro; estas alterações fora do bloco indicado foram revertidas após a clarificação expressa do proprietário. O registo desta revisão é histórico e não descreve a apresentação actual.
|
||||
|
||||
Evidência histórica da revisão superada: `outputs/footer-compact-20261023/qa-results.json`, capturas e `check.mjs`. Não constitui aceitação do âmbito pelo proprietário.
|
||||
|
||||
Correcção expressa da Tempo, 23/10/2026: o pedido abrange exclusivamente o bloco “INVESTE Moçambique / Portal do Investidor — conheça as oportunidades… / Acesso à gestão”. Em `pages/PortalInvestidor.tsx`, esse bloco fica abreviado num ícone de acesso à gestão, com nome acessível e descrição “Acesso à gestão”, destino `/acesso-gestao`, área de interacção de 44 px e fundo branco, sem o título e parágrafo anteriores. O botão de apoio foi restaurado exactamente à apresentação anterior à abreviação: “Contactar Apoio ao Investidor”, compacto, vermelho e sem ícone. `components/tempo-footer.tsx` e os estilos `.tempo-footer` foram integralmente restaurados ao estado anterior, incluindo contactos e ligações visíveis e o fundo original do rodapé geral. Não se altera autenticação nem dados. Esta correcção substitui a interpretação indevida acima e mantém as restantes alterações previamente pedidas.
|
||||
|
||||
Evidência da correcção: `outputs/footer-scope-correction-20261023/check.mjs`, `qa-results.json`, capturas e `build-local.log`; componentes reais com API sintética a 320, 390 e 1440 px, ícone de gestão focável, bloco específico claro sem parágrafo, botão de apoio completo/vermelho, rodapé geral restaurado, contactos e ligações legais visíveis, sem transbordo ou erros JavaScript. Lint dos componentes alterados e compilação local. Referência na linha existente `MASTER-efd550fd-0104`, sem encerramento do critério amplo nem alteração dos seus estados.
|
||||
|
||||
Continuação expressa da Tempo, 23/10/2026: acrescentado o título visível “Acesso à gestão” ao lado do ícone nesse mesmo bloco, para tornar o destino compreensível. O acesso mantém-se compacto, vermelho e com fundo claro, conservando `/acesso-gestao`. Ajuste restrito a essa ligação; apoio e rodapé geral conservam a apresentação restaurada. Referência: `MASTER-efd550fd-0104` e a evidência da revisão anterior acima. Verificado a 320, 390 e 1440 px, sem transbordo ou erros JavaScript; lint e compilação aprovados. Evidência em `outputs/management-label-20261023/qa-results.json`, capturas e `build-local.log`. No pedido seguinte, a Tempo determinou o alinhamento desse acesso à esquerda; aplicado `justify-start` apenas no seu contentor.
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: adaptar Áreas de Investimento do tempo-dashboard aos sectores da home e ao catálogo completo, copiar a estrutura dos botões e uniformizar a vermelho os títulos de secção da home.",
|
||||
"reference": {
|
||||
"project": "D:/Projects/Tempo_Investidor/tempo-dashboard",
|
||||
"files": [
|
||||
"resources/js/components/investor-portal/homepage/homepage-sectors.tsx",
|
||||
"resources/js/pages/investor-portal/sectors/index.tsx"
|
||||
],
|
||||
"structure": "Cinco cartões com fotografia, título sobreposto, resumo e acção de leitura; botão central arredondado/contorno vermelho para o catálogo."
|
||||
},
|
||||
"analysisAndPlan": "docs/investmoz/public-portal-sections.md#sectores-da-home-e-catálogo-completo--23102026",
|
||||
"filesChanged": [
|
||||
"resources/js/components/public-sector-cards.tsx",
|
||||
"resources/js/lib/portal-presentation.ts",
|
||||
"resources/js/components/investment-context-cards.tsx",
|
||||
"resources/js/lib/portal-api.ts",
|
||||
"resources/js/pages/PortalInvestidor.tsx",
|
||||
"resources/js/pages/portal/services.tsx",
|
||||
"tools/portal-home-sectors-qa.mjs",
|
||||
"docs/investmoz/public-portal-sections.md",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv"
|
||||
],
|
||||
"behaviour": {
|
||||
"homePreview": 5,
|
||||
"previewOrder": "Ordem recebida da API, sem novos filtros de publicação.",
|
||||
"readMore": "Ler mais, para a URL recebida ou ficha existente /sectores/{slug}.",
|
||||
"moreSectors": "Ver mais sectores, botão arredondado para /sectores.",
|
||||
"catalogue": "Todos os sectores publicados devolvidos pelo cadastro existente; Voltar ao início para /.",
|
||||
"specializations": "Fintech conservado como ligação própria; sem âncoras aninhadas.",
|
||||
"countsAndDescriptions": "Preservados dados recebidos, incluindo zero e descrições ausentes; não importados textos ou registos do outro projecto.",
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"supportCard": "Superfície clara para contraste com o título vermelho."
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\agronegocio.jpg",
|
||||
"sourceBytes": 444057,
|
||||
"original": "outputs/home-sectors-20261023/originals/agronegocio.jpg",
|
||||
"sha256": "7337b689a6ad8b0b4c8d716ea356db14e22bd2982089825bb2382121ed3482f6",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/agronegocio.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 110310,
|
||||
"sha256": "3e9d3be22556300f880be39837f86e764e3d05433beff1da6694ef6cee1d5c34"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/agronegocio-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 31592,
|
||||
"sha256": "fd1f7624f7fd9224fc1c7bd11746806e419c645ba43c257b1edd9ba250cd47f1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\economia-azul.jpg",
|
||||
"sourceBytes": 456892,
|
||||
"original": "outputs/home-sectors-20261023/originals/economia-azul.jpg",
|
||||
"sha256": "1a2c04d0d9ed4a26129553daefd2ee18f6d820026ab1203acf3bcf91b0550f69",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/economia-azul.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 107626,
|
||||
"sha256": "bf3751e66839c1b3c585c91ffe65d32b9ee79ebd7d08e5e2c95c6cd581b04f8f"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/economia-azul-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 29350,
|
||||
"sha256": "aaf8ee66709d8e15c4304690dd03830b4368d14468a597d5617ccdb71d614331"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\energia.jpg",
|
||||
"sourceBytes": 386678,
|
||||
"original": "outputs/home-sectors-20261023/originals/energia.jpg",
|
||||
"sha256": "a164e2c8ef155a6a198ae400feca132e79ba75e022473893ec48ebfa0557f679",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/energia.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 81236,
|
||||
"sha256": "b5605d9bb8645c5042baa511aaf39fcb187080a48d8e067b130fc974c4c9196d"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/energia-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 21926,
|
||||
"sha256": "7e53977fc6ce11755455f8092ac3f8c7126f54d909a3935e522f8dae0ab834be"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\industria.jpg",
|
||||
"sourceBytes": 357131,
|
||||
"original": "outputs/home-sectors-20261023/originals/industria.jpg",
|
||||
"sha256": "d70c4257ac58a4b5e9f8b8b1a4cf6d98c0514043442c3ce46f5c98399038527f",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/industria.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 80422,
|
||||
"sha256": "ca8ddd2d65647f1ed7a9906271fa2ea3d027f310b89434fd7781a37a3d18322d"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/industria-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 25194,
|
||||
"sha256": "8bd695231209c0042b94e68b0e2095fa7202297708dabff0f78b337500b8c9b7"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\infraestruturas.jpg",
|
||||
"sourceBytes": 395383,
|
||||
"original": "outputs/home-sectors-20261023/originals/infraestruturas.jpg",
|
||||
"sha256": "ac6cde89507af68991faebee19540f955146724d3be901839b85a4098fe1674b",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/infraestruturas.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 86654,
|
||||
"sha256": "14754a59477e5c62a62c190be812c89d9616608b3c98acb6100ad70d94d34df0"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/infraestruturas-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 25772,
|
||||
"sha256": "eca58096087d05dae3eea2b4d063c103f9fc85b0ea20fc5051313cf9bfa1020f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\logistica-transportes.jpg",
|
||||
"sourceBytes": 413702,
|
||||
"original": "outputs/home-sectors-20261023/originals/logistica-transportes.jpg",
|
||||
"sha256": "5976ead23da0d4e7d5debf875262f0229d389826aaeb7e7be4ebbac3f5129a9e",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/logistica-transportes.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 96254,
|
||||
"sha256": "0d235e7bbd160cccd3f97daa3337d6b27fde49db9782ad2ba0599c6a83022b6c"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/logistica-transportes-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 28816,
|
||||
"sha256": "32f5137e0a7f6a7894b186edb2ca38f12cdfdca04dda3588de3969fb3d2e333a"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\mineracao.jpg",
|
||||
"sourceBytes": 415161,
|
||||
"original": "outputs/home-sectors-20261023/originals/mineracao.jpg",
|
||||
"sha256": "7e6d1e5a17d434456ad937680a6c5501bd33bd624d2cc7ebf4b643185a9440aa",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/mineracao.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 96642,
|
||||
"sha256": "12c93df378f54108b13260c10d29789564af7844cc84bcd9281d2be917d83354"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/mineracao-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 26034,
|
||||
"sha256": "be3e7763fb8cd2e02f576204269821c40d93a7c155d8088a5084064b7269e4d6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\outros-sectores.jpg",
|
||||
"sourceBytes": 328152,
|
||||
"original": "outputs/home-sectors-20261023/originals/outros-sectores.jpg",
|
||||
"sha256": "4aaac0869dc7e61c4c7a87e1af95beb108aa9be0da082702f6f70722e62b1e7a",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/outros-sectores.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 75414,
|
||||
"sha256": "f47b3d0da5b0cd5153d393c6f779484ba89043e50195e968338108e893a88077"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/outros-sectores-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 24936,
|
||||
"sha256": "a4fc00551d75c96a1648ad4f81d759d4d6ecd7e6fc5689689216a96934bd1ea2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\servicos-financeiros.jpg",
|
||||
"sourceBytes": 348386,
|
||||
"original": "outputs/home-sectors-20261023/originals/servicos-financeiros.jpg",
|
||||
"sha256": "f932a56248d5544bff92aa30f4af3a5a63044c311969d4370d06f157b097d570",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/servicos-financeiros.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 72144,
|
||||
"sha256": "15379485db7de7b2efebe8bdbd4cbd8e911eb34ab8a23b3dd114c849bb4ae65c"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/servicos-financeiros-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 20698,
|
||||
"sha256": "c21949201e35e63625d3d2e7520e10912fe65be1186954142b2784f6efca51ed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\tic-economia-digital.jpg",
|
||||
"sourceBytes": 417398,
|
||||
"original": "outputs/home-sectors-20261023/originals/tic-economia-digital.jpg",
|
||||
"sha256": "b09703bd079f55fb5a8ec3167ce96e1fdb1e5090813a81c817b9a33909427d94",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/tic-economia-digital.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 97892,
|
||||
"sha256": "b1d79c1a941085582d6a72799140bafed1bf38ec0201a4dfe5f6faf54ea55ae8"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/tic-economia-digital-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 28242,
|
||||
"sha256": "c4279a27ed402ca2feb62f11a8a954cbcae993912d3c8c27a2d9644fdf922114"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "D:\\Projects\\Tempo_Investidor\\tempo-dashboard\\storage\\app\\public\\investor-portal\\sectors\\turismo.jpg",
|
||||
"sourceBytes": 393339,
|
||||
"original": "outputs/home-sectors-20261023/originals/turismo.jpg",
|
||||
"sha256": "1a76db252bc8c1a9dc1f3c650bd95e55337c36a6f604753d61cad96c19b1875f",
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/sectors/turismo.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 93234,
|
||||
"sha256": "de46707dfdd8b98c48ec88d19f3df0b1d6c130adfcbd97d5a7c2e6fa2db62bef"
|
||||
},
|
||||
{
|
||||
"file": "public/images/sectors/turismo-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 27012,
|
||||
"sha256": "4d3d9a04b9426474dca9ba8895021b48fffbe7e4d4a4551272b8c832cf83488e"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"assetMapping": {
|
||||
"agronegocio": "agronegocio",
|
||||
"turismo": "turismo",
|
||||
"infraestruturas": "infraestruturas",
|
||||
"economia-azul": "economia-azul",
|
||||
"mercado-financeiro": "servicos-financeiros",
|
||||
"mineracao": "mineracao",
|
||||
"energia-especifica": "energia",
|
||||
"imobiliario": "outros-sectores",
|
||||
"bens-de-consumo-e-franquias": "industria",
|
||||
"tecnologias-de-informacao": "tic-economia-digital"
|
||||
},
|
||||
"assetSource": "JPG locais do projecto de referência indicado pelo proprietário, inventariados e copiados sem alteração; WebP derivados para apresentação. Sem alegação adicional de licença externa, origem oficial ou localização documental. Imagem genérica reutilizada como alternativa funcional e para sectores sem imagem específica.",
|
||||
"assetBytes960": 997828,
|
||||
"assetBytes480": 289572,
|
||||
"verification": {
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"homeSectors": 5,
|
||||
"catalogueSectors": 10,
|
||||
"homeColumns": 2,
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"homeSectors": 5,
|
||||
"catalogueSectors": 10,
|
||||
"homeColumns": 2,
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"homeSectors": 5,
|
||||
"catalogueSectors": 10,
|
||||
"homeColumns": 3,
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"homeSectors": 5,
|
||||
"catalogueSectors": 10,
|
||||
"homeColumns": 5,
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"homeSectors": 5,
|
||||
"catalogueSectors": 10,
|
||||
"homeColumns": 5,
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"homeSectors": 5,
|
||||
"catalogueSectors": 10,
|
||||
"homeColumns": 5,
|
||||
"headings": [
|
||||
{
|
||||
"title": "Encontre o contexto do seu investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Sectores de investimento",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Como Investir",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Oportunidades em destaque",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Documentos essenciais",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
},
|
||||
{
|
||||
"title": "Apoio ao Investidor",
|
||||
"size": "24px",
|
||||
"weight": "700",
|
||||
"color": "rgb(237, 28, 36)",
|
||||
"lineHeight": "32px"
|
||||
}
|
||||
],
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"fail": "primary",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"fail": "all",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"emptyCatalogue": true,
|
||||
"status": "passed"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"navigation": [
|
||||
"/sectores",
|
||||
"/sectores",
|
||||
"/sectores",
|
||||
"/sectores",
|
||||
"/sectores",
|
||||
"/sectores/agronegocio",
|
||||
"/sectores",
|
||||
"/sectores/turismo",
|
||||
"/sectores",
|
||||
"/sectores/infraestruturas",
|
||||
"/sectores",
|
||||
"/sectores/economia-azul",
|
||||
"/sectores",
|
||||
"/sectores/mercado-financeiro",
|
||||
"/sectores",
|
||||
"/sectores/mineracao",
|
||||
"/sectores",
|
||||
"/sectores/energia-especifica",
|
||||
"/sectores",
|
||||
"/sectores/imobiliario",
|
||||
"/sectores",
|
||||
"/sectores/bens-de-consumo-e-franquias",
|
||||
"/sectores",
|
||||
"/sectores/tecnologias-de-informacao",
|
||||
"/sectores",
|
||||
"/sectores/mercado-financeiro/fintech",
|
||||
"/sectores",
|
||||
"/",
|
||||
"/sectores",
|
||||
"/sectores"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"script": "tools/portal-home-sectors-qa.mjs",
|
||||
"screenshots": [
|
||||
"outputs/home-sectors-20261023/catalogue-1024.png",
|
||||
"outputs/home-sectors-20261023/catalogue-1440.png",
|
||||
"outputs/home-sectors-20261023/catalogue-1920.png",
|
||||
"outputs/home-sectors-20261023/catalogue-320.png",
|
||||
"outputs/home-sectors-20261023/catalogue-390.png",
|
||||
"outputs/home-sectors-20261023/catalogue-768.png",
|
||||
"outputs/home-sectors-20261023/fallback-all.png",
|
||||
"outputs/home-sectors-20261023/fallback-primary.png",
|
||||
"outputs/home-sectors-20261023/home-sectors-1024.png",
|
||||
"outputs/home-sectors-20261023/home-sectors-1440.png",
|
||||
"outputs/home-sectors-20261023/home-sectors-1920.png",
|
||||
"outputs/home-sectors-20261023/home-sectors-320.png",
|
||||
"outputs/home-sectors-20261023/home-sectors-390.png",
|
||||
"outputs/home-sectors-20261023/home-sectors-768.png",
|
||||
"outputs/home-sectors-20261023/homepage-1440.png"
|
||||
],
|
||||
"existingFrontendTests": {
|
||||
"command": "node --test tests/frontend/portal-api.test.mjs tests/frontend/portal-navigation.test.mjs",
|
||||
"passed": 20
|
||||
},
|
||||
"types": {
|
||||
"command": "npm run types:check",
|
||||
"exitCode": 0
|
||||
},
|
||||
"lint": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"exitCode": 0,
|
||||
"assets": {
|
||||
"resources/js/pages/PortalInvestidor.tsx": "assets/PortalInvestidor-Q1OJxvfZ.js",
|
||||
"resources/js/pages/portal/services.tsx": "assets/services-DiuG4Zkv.js"
|
||||
},
|
||||
"log": "outputs/home-sectors-20261023/build-local.log"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"appliedLocally": true,
|
||||
"databaseChanged": false,
|
||||
"migrationsRun": false,
|
||||
"businessRulesChanged": false,
|
||||
"routesChanged": false,
|
||||
"sourceProjectChanged": false,
|
||||
"newDependencies": false,
|
||||
"externalDeployment": false
|
||||
},
|
||||
"limits": "Interface e navegação exercitadas com API e páginas públicas em fixtures; não equivale a demonstração integral dos workflows operacionais ou de critérios mais amplos da matriz.",
|
||||
"matrixReferences": [
|
||||
"MASTER-efd550fd-0104",
|
||||
"MASTER-9f5719c8-0077"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"decision": "Pedido expresso da Tempo: analisar e acrescentar imagens adequadas exclusivamente aos três cartões de Como Investir.",
|
||||
"analysisAndPlan": "docs/investmoz/public-portal-sections.md#fotografias-nas-etapas-de-como-investir--23102026",
|
||||
"source": {
|
||||
"type": "Fotografias ilustrativas geradas por IA, sem atribuição a pessoas, instituições, locais ou projectos reais.",
|
||||
"tool": "image_gen, modo integrado",
|
||||
"skill": "C:/Users/jorge/.codex/skills/.system/imagegen/SKILL.md",
|
||||
"externalPhotographsUsed": false
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"key": "explorar",
|
||||
"prompt": "Use case: photorealistic-natural. Asset for a small navigation card on a Mozambique investor portal. Create one individual photorealistic editorial photograph, horizontal 16:9, professional restrained style, natural soft daylight, realistic skin and materials, neutral colors, documentary composition readable at 350px wide. Illustration of a plausible setting, no identifiable named project or official building. No text, lettering, logos, watermarks, flags, maps, diagrams, graphic overlays, montage, or advertisement styling. Keep main subject inside the central 80% for responsive crop. Stage 1 EXPLORE opportunities, sector and territory: two Mozambican business professionals, a woman and man in smart casual clothing, standing outdoors by a quiet access road at a modest business park, naturally discussing a tablet held by the woman. Generic unbranded low warehouse buildings and green productive land visible in background, credible southern African environment. Eye-level medium-wide candid composition, both people three-quarter view, looking at tablet and surroundings rather than camera. Business evaluation and discovery, no construction work or hard hats.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-42eb9338-4350-40bf-9098-858c12994189.png",
|
||||
"original": "outputs/investment-steps-20261023/originals/explorar.png",
|
||||
"originalSha256": "a3c4ec1a0d2deaf87e0c8e765616c74ab70067eade9a862effce9e6befbbccb6",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-steps/explorar.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 52266,
|
||||
"sha256": "c3e151ae649ce3cb66f5babc39978fa5803d320cacc95a27e798db4ec2d45a4c"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-steps/explorar-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 19312,
|
||||
"sha256": "d3f383d9a58b23fada37472ea853f985aca8c3146123747d777b27704d32c7e0"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-steps/explorar-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 5300,
|
||||
"sha256": "ba12ac4d2e81ac8a2c2e681667ccb08a3abfdba24de39e6e942d6378b700f2a9"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "preparar",
|
||||
"prompt": "Use case: photorealistic-natural. Asset for a small navigation card on a Mozambique investor portal. Create one individual photorealistic editorial photograph, horizontal 16:9, professional restrained style, natural soft daylight, realistic skin and materials, neutral colors, documentary composition readable at 350px wide. Illustration of a plausible setting, no identifiable named project or official building. No text, lettering, logos, watermarks, flags, maps, diagrams, graphic overlays, montage, or advertisement styling. Keep main subject inside the central 80% for responsive crop. Stage 2 PREPARE investment and organize documents: a Mozambican woman entrepreneur and a male business adviser seated at a light wood office desk, calmly reviewing and sorting a small tidy set of document folders and loose sheets beside a closed laptop. One points to a page while the other organizes a folder. No visible readable writing or personal data; papers at an angle. Bright modest professional meeting room, view from slightly above table height, hands and folders clear in foreground, natural candid interaction, realistic anatomy.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-5f07618a-1e86-42d9-aaf8-3acd0abf2164.png",
|
||||
"original": "outputs/investment-steps-20261023/originals/preparar.png",
|
||||
"originalSha256": "679a6875bb3ec79a9b71c27c9d785334376746be2bb305f461db9e19b0a0b7ea",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-steps/preparar.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 57942,
|
||||
"sha256": "6de28042d3abce238a3680fb878779682f3672c3feb97b7e42b72e9a0a0ffbac"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-steps/preparar-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 19950,
|
||||
"sha256": "671ba0fc207f85833b91027d3f6dad91599e95702cfea439e558906f92a2bfac"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-steps/preparar-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 6008,
|
||||
"sha256": "645b34cd60b31fdc8e6586dedb71061c08add9ff985ce90909333b0bd1e0aa3e"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "acompanhar",
|
||||
"prompt": "Use case: photorealistic-natural. Asset for a small navigation card on a Mozambique investor portal. Create one individual photorealistic editorial photograph, horizontal 16:9, professional restrained style, natural soft daylight, realistic skin and materials, neutral colors, documentary composition readable at 350px wide. Illustration of a plausible setting, no identifiable named project or official building. No text, lettering, logos, watermarks, flags, maps, diagrams, graphic overlays, montage, or advertisement styling. Keep main subject inside the central 80% for responsive crop. Stage 3 FOLLOW UP submitted request and protocol: a Mozambican male entrepreneur seated at a tidy desk using a laptop to check an online application, one hand naturally on trackpad, a small closed document folder nearby. Three-quarter side view showing focused face and laptop, warm daylight from side window, modest contemporary office with soft greenery out of focus. Laptop screen is oblique and softly out of focus with only indistinct neutral interface rows, no readable text, official seal, logo or invented dashboard details. Convey calm digital self-service and progress checking, not a call centre or signing documents.",
|
||||
"source": "C:\\Users\\jorge\\.codex\\generated_images\\01a167bd-3f26-71a0-8ec3-3f17519d2bfd\\exec-f896c5f8-19a0-49b0-af8e-0d12bfe4c7aa.png",
|
||||
"original": "outputs/investment-steps-20261023/originals/acompanhar.png",
|
||||
"originalSha256": "17d453445f2afb4c7641909950d2ecfb8276c433a4e28eeee94d0a57d1bdad46",
|
||||
"originalDimensions": {
|
||||
"width": 1672,
|
||||
"height": 941
|
||||
},
|
||||
"derivatives": [
|
||||
{
|
||||
"file": "public/images/investment-steps/acompanhar.webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"bytes": 43722,
|
||||
"sha256": "fe764c8161de36ee597f856face5341260670d1a28ab482bd01a0f5120ad0588"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-steps/acompanhar-480.webp",
|
||||
"width": 480,
|
||||
"height": 270,
|
||||
"bytes": 16176,
|
||||
"sha256": "79ceaecb0ef23c6729e43d04b7e91493d6784ea9f658d540dda3550702dc3623"
|
||||
},
|
||||
{
|
||||
"file": "public/images/investment-steps/acompanhar-fallback.webp",
|
||||
"width": 240,
|
||||
"height": 135,
|
||||
"bytes": 5298,
|
||||
"sha256": "cd4530cfef4f19f23f640ceae59cfd73d0ee269643118b458f2a4748f5845fc6"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"title": "1. Explorar",
|
||||
"description": "Identifique o sector, território e oportunidade.",
|
||||
"href": "/oportunidades",
|
||||
"image": "explorar"
|
||||
},
|
||||
{
|
||||
"title": "2. Preparar",
|
||||
"description": "Conheça o percurso e organize os documentos.",
|
||||
"href": "/como-investir",
|
||||
"image": "preparar"
|
||||
},
|
||||
{
|
||||
"title": "3. Acompanhar",
|
||||
"description": "Envie o pedido e acompanhe o protocolo.",
|
||||
"href": "/balcao-virtual",
|
||||
"image": "acompanhar"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"component": "resources/js/components/investment-steps.tsx",
|
||||
"homeIntegration": "resources/js/pages/PortalInvestidor.tsx",
|
||||
"aspectRatio": "16:9",
|
||||
"responsiveImages": [
|
||||
960,
|
||||
480
|
||||
],
|
||||
"fallback": "Variante própria de 240 px; se também falhar, espaço reservado com descrição acessível e Imagem indisponível.",
|
||||
"columns": "Uma coluna abaixo de 768 px, três a partir de 768 px.",
|
||||
"compactText": "16 px de preenchimento, sem altura mínima de texto.",
|
||||
"accessibility": "Alt em português, nomes e descrições das âncoras, foco visível, dimensões reservadas e loading lazy.",
|
||||
"headings": "Título vermelho partilhado da home preservado."
|
||||
},
|
||||
"verification": {
|
||||
"browser": {
|
||||
"results": [
|
||||
{
|
||||
"width": 320,
|
||||
"dpr": 1,
|
||||
"fail": "none",
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar-480.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7820512820512822
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar-480.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7820512820512822
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar-480.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7820512820512822
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"dpr": 2,
|
||||
"fail": "none",
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7755102040816326
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7755102040816326
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7755102040816326
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"dpr": 1,
|
||||
"fail": "none",
|
||||
"columns": 3,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar-480.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7734375
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar-480.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7734375
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar-480.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7734375
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"dpr": 1,
|
||||
"fail": "none",
|
||||
"columns": 3,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar-480.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7784090909090908
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar-480.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7784090909090908
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar-480.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7784090909090908
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"dpr": 2,
|
||||
"fail": "none",
|
||||
"columns": 3,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.775
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.775
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.775
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"dpr": 1,
|
||||
"fail": "none",
|
||||
"columns": 3,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar-480.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.775
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar-480.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.775
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar-480.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.775
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"dpr": 2,
|
||||
"fail": "primary",
|
||||
"columns": 1,
|
||||
"photos": [
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/explorar-fallback.webp",
|
||||
"alt": "Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7755102040816326
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/preparar-fallback.webp",
|
||||
"alt": "Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7755102040816326
|
||||
},
|
||||
{
|
||||
"src": "http://127.0.0.1:5174/images/investment-steps/acompanhar-fallback.webp",
|
||||
"alt": "Empreendedor consulta um pedido no computador portátil numa secretária de escritório.",
|
||||
"lazy": "lazy",
|
||||
"ratio": 1.7755102040816326
|
||||
}
|
||||
],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"dpr": 2,
|
||||
"fail": "all",
|
||||
"columns": 1,
|
||||
"photos": [],
|
||||
"layoutShift": false,
|
||||
"overflow": false
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"navigation": [
|
||||
"/oportunidades",
|
||||
"/como-investir",
|
||||
"/balcao-virtual"
|
||||
],
|
||||
"databaseAccess": false
|
||||
},
|
||||
"script": "outputs/investment-steps-20261023/check.mjs",
|
||||
"screenshots": [
|
||||
"outputs/investment-steps-20261023/cards-1024-none.png",
|
||||
"outputs/investment-steps-20261023/cards-1440-none.png",
|
||||
"outputs/investment-steps-20261023/cards-1920-none.png",
|
||||
"outputs/investment-steps-20261023/cards-320-none.png",
|
||||
"outputs/investment-steps-20261023/cards-390-all.png",
|
||||
"outputs/investment-steps-20261023/cards-390-none.png",
|
||||
"outputs/investment-steps-20261023/cards-390-primary.png",
|
||||
"outputs/investment-steps-20261023/cards-768-none.png"
|
||||
],
|
||||
"lint": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"types": {
|
||||
"command": "npm run types:check",
|
||||
"exitCode": 0,
|
||||
"log": "outputs/investment-steps-20261023/types.log"
|
||||
},
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"exitCode": 0,
|
||||
"homeAsset": "assets/PortalInvestidor-C1QTZ3-t.js",
|
||||
"log": "outputs/investment-steps-20261023/build-local.log"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"appliedLocally": true,
|
||||
"otherSectionsChanged": false,
|
||||
"supportAndFooterChanged": false,
|
||||
"databaseAccess": false,
|
||||
"businessRulesChanged": false,
|
||||
"routesChanged": false,
|
||||
"newDependencies": false,
|
||||
"externalDeployment": false
|
||||
},
|
||||
"matrixReferences": [
|
||||
"MASTER-efd550fd-0104"
|
||||
],
|
||||
"limits": "Componentes reais com API sintética e destinos interceptados para verificar a navegação; não demonstra os workflows operacionais completos nem encerra o critério mais amplo da matriz."
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"timezone": "Africa/Johannesburg",
|
||||
"status": "installed_in_homologation_delta_verified",
|
||||
"baseline_commit": "4098192a056959d788e6f9ff5cd501211b001f39",
|
||||
"preserved_acceptance": {
|
||||
"delivery": "6b544548d3c83fc8a72ab65d8f58f84685a2877f",
|
||||
"receipt": "4098192a056959d788e6f9ff5cd501211b001f39",
|
||||
"cases_repeated": false
|
||||
},
|
||||
"cause": {
|
||||
"portfolio_field": "content.focus",
|
||||
"previous_contract": "string rendered as descriptive text; no complementary selection or typed origin-target relationship in the dossier",
|
||||
"data_loss_claimed": false
|
||||
},
|
||||
"scope": {
|
||||
"components": [
|
||||
"irrigation",
|
||||
"storage",
|
||||
"cold",
|
||||
"processing",
|
||||
"spinning",
|
||||
"logistics",
|
||||
"certification",
|
||||
"training",
|
||||
"finance"
|
||||
],
|
||||
"existing_opportunity_identity_preserved": true,
|
||||
"documented_sources": [
|
||||
"portfolio associations including the existing accepted documentary membership",
|
||||
"public complementary_project_links",
|
||||
"existing publicly disclosed value-chain references",
|
||||
"published offering partnerships explicitly associated with the origin"
|
||||
],
|
||||
"distinct_states": [
|
||||
"registered opportunity",
|
||||
"identified complementary need without a matched offer",
|
||||
"existing service or instrument",
|
||||
"previously selected relationship requiring review"
|
||||
],
|
||||
"dossier": [
|
||||
"encrypted existing payload",
|
||||
"draft",
|
||||
"preview and confirmation",
|
||||
"explicit removal and reselection",
|
||||
"existing audit history",
|
||||
"source and territory preserved",
|
||||
"pending edits saved before complementary navigation"
|
||||
],
|
||||
"consumers": [
|
||||
"dossier",
|
||||
"shared prefill component for simulator, land, desk and submission",
|
||||
"simulator context snapshot",
|
||||
"shared report and export rows"
|
||||
]
|
||||
},
|
||||
"validation": {
|
||||
"database": "simulator_fiscal_delta_20261021_test",
|
||||
"host": "127.0.0.1",
|
||||
"port": 55441,
|
||||
"database_identity_guard": true,
|
||||
"test_transaction_rollback": true,
|
||||
"feature_test_file": "tests/Feature/InvestmentComplementsTest.php",
|
||||
"feature_tests_passed": 8,
|
||||
"feature_assertions": 134,
|
||||
"frontend_test_file": "tests/frontend/investment-complements.test.mjs",
|
||||
"frontend_tests_passed": 2,
|
||||
"typescript": "passed",
|
||||
"build": "passed",
|
||||
"php_syntax": "passed",
|
||||
"diff_whitespace_check": "passed",
|
||||
"public_chain_branch": "synthetic public projection contract; not evidence of new real-world chain associations",
|
||||
"browser_visual_verification": "passed_for_the_requested_complement_scope"
|
||||
},
|
||||
"visual_verification": {
|
||||
"date": "2026-10-23",
|
||||
"environment": "http://127.0.0.1:18574",
|
||||
"router": "tools/corridor-agribusiness-qa-router.php",
|
||||
"database_guard_checked_on_requests": true,
|
||||
"actor": "existing synthetic QA actor 509; existing normal browser session",
|
||||
"case_id": "6fdbb963-726e-4048-9884-8cddc04e77a6",
|
||||
"case_title": "SYNTHETIC QA — conferência visual de complementos 23-10-2026",
|
||||
"existing_opportunity": {
|
||||
"id": 286,
|
||||
"reference": "INV-MZ-GEN-8128691124566423",
|
||||
"slug": "inv-mz-gen-8128691124566423",
|
||||
"result": "Opened the existing fiche from the storage complement; the preparation entry retained the portfolio origin and the existing identity. No reimport or duplicate."
|
||||
},
|
||||
"need_without_identified_offer": {
|
||||
"code": "spinning",
|
||||
"origin": "portfolio:a7271336-808b-42b4-8bfd-5b49c72dbe2b",
|
||||
"result": "Started idea preparation in the same dossier; recovered as a complementary need with location still undetermined, without inventing an offer or inheriting Sofala."
|
||||
},
|
||||
"associated_service": {
|
||||
"id": 17,
|
||||
"synthetic_fixture": true,
|
||||
"origin": "opportunity:309",
|
||||
"territory": "Gaza",
|
||||
"result": "Opened the existing QA service fiche, selected it through the return action, confirmed it and recovered it in the same dossier."
|
||||
},
|
||||
"multiple_opportunities": {
|
||||
"first": {
|
||||
"id": 286,
|
||||
"territory": "Sofala"
|
||||
},
|
||||
"second": {
|
||||
"id": 309,
|
||||
"territory": "Gaza",
|
||||
"synthetic_fixture": true
|
||||
},
|
||||
"complementary_opportunity": {
|
||||
"id": 310,
|
||||
"origin_id": 309,
|
||||
"territory": "Gaza",
|
||||
"synthetic_fixture": true
|
||||
},
|
||||
"service_origin_id": 309,
|
||||
"recovered_after_confirmation_and_browser_reload": true,
|
||||
"complements_assigned_to_first_automatically": false
|
||||
},
|
||||
"fixture_preparation": {
|
||||
"previous_service_offerings_in_qa": 0,
|
||||
"new_synthetic_records": "Two projects, two opportunities, one explicit complementary-project link and one associated training offering, solely in the guarded QA database; no grants or real publication/approval claims.",
|
||||
"preexisting_records_preserved_by_hash": {
|
||||
"opportunities": 45,
|
||||
"projects": 17,
|
||||
"investment_dossiers": 30,
|
||||
"portal_partnerships": 0
|
||||
},
|
||||
"fixtures_and_visual_case_persisted_in_qa": true,
|
||||
"fixture_receipt": "outputs/complements-visual-20261023/fixture.json"
|
||||
},
|
||||
"evidence_directory": "outputs/complements-visual-20261023",
|
||||
"automated_suites_repeated": false,
|
||||
"functional_code_changes_in_this_visual_pass": false,
|
||||
"does_not_demonstrate": [
|
||||
"multi-opportunity zones and parks",
|
||||
"specific per-opportunity context in land assistance",
|
||||
"specific per-opportunity context in virtual desk",
|
||||
"specific per-opportunity context in manifestation",
|
||||
"availability of a real training or financing service in homologation"
|
||||
],
|
||||
"evidence_files": [
|
||||
{
|
||||
"path": "outputs/complements-visual-20261023/existing-opportunity.png",
|
||||
"bytes": 112261,
|
||||
"sha256": "93425703b17c0976a5728acc7448faa0b7dd73e34973cb68ebf72cb9d416dd58"
|
||||
},
|
||||
{
|
||||
"path": "outputs/complements-visual-20261023/associated-service.png",
|
||||
"bytes": 117223,
|
||||
"sha256": "a5d16746b4264b779430cc3843959492bea41a3da2adc00298fa2133c99aef9d"
|
||||
},
|
||||
{
|
||||
"path": "outputs/complements-visual-20261023/recovered-opportunities.png",
|
||||
"bytes": 99910,
|
||||
"sha256": "eb65dd0d42af0c9f553db6756760dcc16f598b2da7b3d0157b6faea3dbebf24e"
|
||||
},
|
||||
{
|
||||
"path": "outputs/complements-visual-20261023/recovered-complements.png",
|
||||
"bytes": 147972,
|
||||
"sha256": "4e2db825710665298803542fe9c7f299404962503609948ddd083c3daf195a73"
|
||||
},
|
||||
{
|
||||
"path": "outputs/complements-visual-20261023/recovered-visible-state.json",
|
||||
"bytes": 2304,
|
||||
"sha256": "d2ada1b501db973f1492a54f1704e9cb2de112e38cacd4ab916f059dbbd11f18"
|
||||
},
|
||||
{
|
||||
"path": "outputs/complements-visual-20261023/fixture.json",
|
||||
"bytes": 904,
|
||||
"sha256": "79fa2ac8196ca5620b258c78332a2462b44f2c5b622fc0c1895fb39199efcd67"
|
||||
}
|
||||
]
|
||||
},
|
||||
"preservation": {
|
||||
"active_database_writes": false,
|
||||
"homologation_installation": false,
|
||||
"migration": false,
|
||||
"publication": false,
|
||||
"import": false,
|
||||
"new_roles_or_grants": false,
|
||||
"new_matrix_or_backlog": false
|
||||
},
|
||||
"matrix_rows": [
|
||||
"RF-CLU-003",
|
||||
"RF-COR-004",
|
||||
"AUD-REL-02",
|
||||
"AUD-LEG-06",
|
||||
"AUD-SIM-INT06"
|
||||
],
|
||||
"limitations": [
|
||||
"Visual verification is limited to the documented QA scenarios; no Tempo acceptance or destination installation is claimed.",
|
||||
"The resolver covers the nine requested component scopes in available documentary contexts; unmatched expressions and absent relationships are not fabricated.",
|
||||
"No matched offer means none identified in the examined relationships, not proof that the entire collection has no offer.",
|
||||
"The existing first-opportunity zone/park selection and complete per-opportunity requirements in other forms remain partial.",
|
||||
"No global completion of the adaptive journey is claimed."
|
||||
],
|
||||
"installation": {
|
||||
"status": "installed_delta_verified",
|
||||
"date": "2026-10-23",
|
||||
"target": "https://teste-investidor.tempo.co.mz",
|
||||
"served_commit": "d8224ba84b56482e0291bc18bb1c9c29316b19b9",
|
||||
"installer": {
|
||||
"status": "installed",
|
||||
"commit": "d8224ba84b56482e0291bc18bb1c9c29316b19b9",
|
||||
"release": "/var/www/investmoz-homologacao/releases/d8224ba84b56482e0291bc18bb1c9c29316b19b9-c0d7c675",
|
||||
"previous_release": "/var/www/investmoz-homologacao/releases/6b544548d3c83fc8a72ab65d8f58f84685a2877f-b38d94da",
|
||||
"database_migrations": 0,
|
||||
"content_imports": 0,
|
||||
"requested_commits": [
|
||||
"d8224ba84b56482e0291bc18bb1c9c29316b19b9"
|
||||
],
|
||||
"finished_at": "2026-10-23T15:12:40.124788+00:00"
|
||||
},
|
||||
"public_revision": {
|
||||
"commit": "d8224ba84b56482e0291bc18bb1c9c29316b19b9",
|
||||
"branch": "main",
|
||||
"built_at": "2026-10-23T15:12:37.169518+00:00",
|
||||
"manifest_sha256": "52a021c1281e72d2aabe4a8fad82a150857e851d917c28e6898d2d439dfb771f"
|
||||
},
|
||||
"verification": {
|
||||
"qa_guard_assertions": 3106,
|
||||
"unit_tests": 57,
|
||||
"unit_assertions": 309,
|
||||
"frontend_tests": 208,
|
||||
"frontend_failures": 0,
|
||||
"public_real_fiche_opened": "INV-MZ-GEN-6048328347519124",
|
||||
"real_target_id": 48,
|
||||
"origin_preserved": "portfolio:a7271336-808b-42b4-8bfd-5b49c72dbe2b",
|
||||
"spinning": "need_without_identified_offer_location_undefined",
|
||||
"service": "existing_publication_states_no_synthetic_fixture_transferred",
|
||||
"manual_business_cases_repeated": false
|
||||
},
|
||||
"preservation": {
|
||||
"concurrent_main": "43cd0be7cbe2450f1928b059f1456454f23943ef",
|
||||
"integration": "088f2c2cf47f5b16f351e231c490e848a664e433",
|
||||
"accepted_delivery": "6b544548",
|
||||
"accepted_receipt": "4098192a",
|
||||
"database_migrations": 0,
|
||||
"content_imports": 0,
|
||||
"qa_fixture_payload_shipped": false,
|
||||
"transversal_coverage": "partial"
|
||||
},
|
||||
"screenshot": {
|
||||
"path": "outputs/complements-install-20261023/installed-complements.png",
|
||||
"sha256": "9d48d2a51d5fbb1c0f5e92247af399c582444b329406416c172848ee7b1cb1e4"
|
||||
},
|
||||
"pending": [
|
||||
"multi-opportunity specific contexts in zones/parks, Terra/DUAT, Balcao and manifestation remain to be demonstrated"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"status": "assessment_completed_gaps_confirmed_not_corrected",
|
||||
"code_revision": "af9261809c6fece9ea3c7c79b49a80562f2ace68",
|
||||
"accepted_complements_delivery": "d8224ba8",
|
||||
"prior_closes": [
|
||||
"6b544548",
|
||||
"4098192a"
|
||||
],
|
||||
"accepted_cases_repeated": false,
|
||||
"homologation_changed": false,
|
||||
"probe": {
|
||||
"date": "2026-10-23",
|
||||
"qa_database": "simulator_fiscal_delta_20261021_test",
|
||||
"qa_port": 55441,
|
||||
"case_id": "6fdbb963-726e-4048-9884-8cddc04e77a6",
|
||||
"transaction_rolled_back": true,
|
||||
"first_choice_exactly_preserved": true,
|
||||
"second_before": {
|
||||
"key": "opportunity:309",
|
||||
"territory": "Gaza",
|
||||
"corridor": "limpopo"
|
||||
},
|
||||
"second_after_recovered": {
|
||||
"key": "opportunity:309",
|
||||
"territory": "Manica",
|
||||
"corridor": "beira"
|
||||
},
|
||||
"third_choice_preserved": true,
|
||||
"consumers": {
|
||||
"land": {
|
||||
"unchanged_after_second_change": true,
|
||||
"before": {
|
||||
"project_reference": "SYNTHETIC QA — conferência visual de complementos 23-10-2026",
|
||||
"activity": "Agronegócio",
|
||||
"purpose": "Produção, silos, moagem e processamento de milho, trigo e arroz.",
|
||||
"parcels": [
|
||||
{
|
||||
"province_id": "f57ff041-6275-57b9-8800-c35e46e0d475"
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"project_reference": "SYNTHETIC QA — conferência visual de complementos 23-10-2026",
|
||||
"activity": "Agronegócio",
|
||||
"purpose": "Produção, silos, moagem e processamento de milho, trigo e arroz.",
|
||||
"parcels": [
|
||||
{
|
||||
"province_id": "f57ff041-6275-57b9-8800-c35e46e0d475"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"desk": {
|
||||
"unchanged_after_second_change": false,
|
||||
"before": {
|
||||
"title": "SYNTHETIC QA — conferência visual de complementos 23-10-2026",
|
||||
"territory_id": "f57ff041-6275-57b9-8800-c35e46e0d475",
|
||||
"location_source": "Referência consultada no contexto v2: Sofala. Não confirma disponibilidade.",
|
||||
"description": "Produção, silos, moagem e processamento de milho, trigo e arroz.",
|
||||
"sector": "Agronegócio",
|
||||
"province_id": "f57ff041-6275-57b9-8800-c35e46e0d475"
|
||||
},
|
||||
"after": {
|
||||
"title": "SYNTHETIC QA — conferência visual de complementos 23-10-2026",
|
||||
"territory_id": "f57ff041-6275-57b9-8800-c35e46e0d475",
|
||||
"location_source": "Referência consultada no contexto v3: Sofala. Não confirma disponibilidade.",
|
||||
"description": "Produção, silos, moagem e processamento de milho, trigo e arroz.",
|
||||
"sector": "Agronegócio",
|
||||
"province_id": "f57ff041-6275-57b9-8800-c35e46e0d475"
|
||||
}
|
||||
},
|
||||
"submission": {
|
||||
"unchanged_after_second_change": true,
|
||||
"before": {
|
||||
"sector": "Agronegócio",
|
||||
"objective": "Produção, silos, moagem e processamento de milho, trigo e arroz.",
|
||||
"location": "Sofala"
|
||||
},
|
||||
"after": {
|
||||
"sector": "Agronegócio",
|
||||
"objective": "Produção, silos, moagem e processamento de milho, trigo e arroz.",
|
||||
"location": "Sofala"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parks": {
|
||||
"ui_request_origin": "choices[0]",
|
||||
"first_context": {
|
||||
"corridor": "beira",
|
||||
"territory": "Sofala"
|
||||
},
|
||||
"first_options": [],
|
||||
"second_options_before": [],
|
||||
"second_options_after": [],
|
||||
"separate_park_slot_available": false
|
||||
},
|
||||
"revision_before": 2,
|
||||
"revision_inside_transaction": 3,
|
||||
"complement_acceptance_retested": false,
|
||||
"original_case_exactly_preserved": true
|
||||
},
|
||||
"cause": {
|
||||
"parks": "context.tsx derives request parameters and effect dependencies solely from choices[0]; selection.park and server compatibility validation are common and based on the first choice.",
|
||||
"land": "InvestorContext::prefill generates one parcel from common fields; form.tsx applies only v[0] to the first parcel. No opportunity identity is attached to that proposal.",
|
||||
"desk": "prefill uses common fields and first territory. A second-choice change updates the context revision text but retains first-choice territory and description.",
|
||||
"submission": "prefill uses common sector, summary and location. No second-choice scope is supplied.",
|
||||
"display": "DossierPrefill renders OpportunityChoices only for simulator; the other three consumers show common selection. Complement relations retain their independent existing display and are not retested."
|
||||
},
|
||||
"evidence_limits": [
|
||||
"Commit/recovery exercised through existing application services inside a rolled-back QA transaction, not a new browser-session acceptance.",
|
||||
"QA park option sets were empty for the sampled locations; no positive park availability or loss of a registered park is claimed.",
|
||||
"Terra/DUAT current UI inspected read-only; desk and submission conclusions are based on executed prefill and source contracts, not submitted operations.",
|
||||
"Deliberate form edits, save/recovery of the three destination forms and per-opportunity park selection remain acceptance criteria for a correction."
|
||||
],
|
||||
"visual_land": {
|
||||
"path": "outputs/multi-opportunity-review-20261023/land-visible.txt",
|
||||
"sha256": "5a2be5f9d0f6f0073d053bfdcb4523cf6e531ad3c929c2dbe6a8d4aaa34dfe4d"
|
||||
},
|
||||
"next_correction": "Scope zone selection and destination form proposals to an explicit opportunity identity; preserve other choices and deliberate edits; verify a second-only change and save/recovery in each destination.",
|
||||
"transversal_status": "partial"
|
||||
}
|
||||
@@ -1,5 +1,86 @@
|
||||
# Rastreabilidade, utilização e formação
|
||||
|
||||
## Contexto específico por oportunidade — conferência delimitada, 23/10/2026
|
||||
|
||||
Pedido da Tempo: complementos entregues em `d8224ba8`, sem pendência de disponibilização nem repetição dos seus casos ou dos fechos `6b544548`/`4098192a`. Conferida a alteração apenas da segunda oportunidade nos percursos ainda parciais. [Recibo técnico](multi-opportunity-context-review-20261023.json).
|
||||
|
||||
Reutilizado o dossier sintético QA existente `6fdbb963-726e-4048-9884-8cddc04e77a6`, actor normal QA 509, base guardada `simulator_fiscal_delta_20261021_test:55441`. Prévia, confirmação e recuperação pelos serviços existentes, dentro de transacção revertida: segunda oportunidade `309`, Gaza/Limpopo → Manica/Beira, com confirmação e motivo explícitos. Primeira e terceira escolhas conservadas exactamente. A versão interna passou 2 → 3; após rollback o registo original de `desk_cases` coincidiu integralmente com o anterior. Não se alterou a homologação nem se reavaliaram complementos.
|
||||
|
||||
| Percurso | Resultado e causa delimitada |
|
||||
| --- | --- |
|
||||
| Dossier | A alteração da segunda foi recuperada separadamente; a primeira continuou Sofala e a terceira Gaza. Não há perda geral das localizações neste ensaio. |
|
||||
| Zonas/parques | Consulta e dependências do efeito de `context.tsx` usam apenas `choices[0]`; existe só `selection.park`, validado pelo servidor contra a primeira escolha. Alterar a segunda não muda os parâmetros dessa consulta nem permite atribuir-lhe parque próprio. Os conjuntos QA consultados estavam vazios: não se declara disponibilidade positiva nem eliminação de um parque. |
|
||||
| Terra/DUAT | Antes/depois, a proposta continua uma parcela de Sofala, sem identidade da oportunidade. `prefill()` deriva-a dos campos comuns; o formulário aplica só `v[0]` à primeira parcela. Conferência visual de leitura mostrou as referências comuns e os complementos existentes, sem lista das localizações de cada oportunidade. |
|
||||
| Balcão | Território e descrição continuam os da primeira escolha. A diferença executada é o texto da revisão v2 → v3 na origem territorial; isso não transporta o âmbito da segunda oportunidade. |
|
||||
| Manifestação | Sector, objectivo e localização propostos permanecem iguais, incluindo Sofala. A proposta não identifica a oportunidade a que cada âmbito se destina. |
|
||||
|
||||
**Saldo:** causas confirmadas; estes quatro trechos não ficam fechados nem corrigidos por esta avaliação. O componente comum só apresenta `OpportunityChoices` no simulador. A correcção deve identificar expressamente a oportunidade destinatária das zonas/parques e dos dados propostos em cada formulário, preservando as restantes escolhas e edições deliberadas, e demonstrar guardar/recuperar após mudar apenas a segunda. A confirmação/recuperação deste ensaio foi nos serviços da aplicação, não nova sessão de navegador nem submissão dos três formulários. Evidência local: `outputs/multi-opportunity-review-20261023/probe.json` e `land-visible.txt`. Sem nova matriz/backlog; actualizada apenas a evidência de AUD-LEG-06 e AUD-SIM-INT03 na matriz existente. A aplicação transversal permanece parcial.
|
||||
|
||||
## Complementos — disponibilização em homologação, 23/10/2026
|
||||
|
||||
Por decisão expressa da Tempo, o recorte demonstrado foi integrado pelo Git existente e disponibilizado em https://teste-investidor.tempo.co.mz, revisão `d8224ba8`. A integração `088f2c2c` preservou as alterações concorrentes de `43cd0be7`; `d8224ba8` actualizou apenas o teste da entrada pública do simulador à estrutura da página inicial integrada. O instalador tinha recusado a referência ao componente antigo, também falhada no main concorrente, conservando a release anterior. Não se desactivou a verificação.
|
||||
|
||||
O recibo existente regista a revisão efectivamente servida, a instalação e a conferência delimitada do delta. Mantêm-se as provas QA aceites, sem nova ronda dos mesmos casos. Não houve migrations, importações nem transporte dos exemplos sintéticos. As oportunidades e serviços reais conservam identidades e estados de publicação. Fiação permanece necessidade em preparação, sem localização ou oferta inventadas.
|
||||
|
||||
Os estados «homologação inalterada» e «não instalado» nas passagens históricas abaixo descrevem essas passagens e são superados apenas neste recorte. As pendências de contextos específicos em zonas/parques, Terra/DUAT, Balcão e manifestação continuam abertas; a aplicação transversal permanece parcial. Fechos `6b544548`/`4098192a` preservados.
|
||||
|
||||
## Complementos — conferência visual QA concluída no recorte pedido, 23/10/2026
|
||||
|
||||
Pedido da Tempo: conferir abertura da ficha complementar existente, preparação de necessidade sem oferta identificada, selecção de serviço/instrumento e recuperação de selecção/origem/localização; com várias oportunidades, verificar a ligação à oportunidade escolhida, sem assumir a primeira. Homologação inalterada e fechos anteriores preservados. Esta passagem supera **apenas a pendência visual dos complementos neste recorte** nas secções abaixo; não declara a aplicação transversal concluída.
|
||||
|
||||
Ambiente usado: navegador Edge em `http://127.0.0.1:18574`, servido pelo router QA existente, com guarda efectiva de `simulator_fiscal_delta_20261021_test:55441` em cada pedido. Reutilizada a sessão normal da conta sintética QA 509; sem criar sessão, contornar MFA ou conceder papéis. Criado pela interface o dossier **SYNTHETIC QA — conferência visual de complementos 23-10-2026**, ID `6fdbb963-726e-4048-9884-8cddc04e77a6`; nenhum dossier anterior foi editado.
|
||||
|
||||
| Passagem visual | Resultado demonstrado |
|
||||
| --- | --- |
|
||||
| Abrir oportunidade complementar existente | Silos/armazenagem → Milho, trigo e arroz — Corredor da Beira, ID **286**, referência e slug `INV-MZ-GEN-8128691124566423`/`inv-mz-gen-8128691124566423`, conservados. A entrada de preparação recebeu a relação à carteira e a mesma identidade, sem reimportação. |
|
||||
| Preparar necessidade sem oferta identificada | Acção de Fiação abriu a preparação no mesmo dossier e apresentou **Necessidade complementar — ideia em preparação**. Confirmada e recuperada com origem no Corredor da Beira e Centro. A localização inicialmente não escolhida ficou **Por definir**, sem receber Sofala por associação à primeira oportunidade. |
|
||||
| Seleccionar serviço associado | Ficha do serviço QA **17**, de Formação, aberta e seleccionada por **Seleccionar este complemento e continuar no dossier de origem**. Origem: segunda oportunidade QA **309**; localização: **Gaza**. Recuperadas após confirmação e recarregamento. |
|
||||
| Várias oportunidades, sem assumir a primeira | Primeira ficha **286 → Sofala**; segunda **309 → Gaza**. O complemento de frio **310** e Formação **17** ficaram ligados à **309**, em **Gaza**; a primeira ficha continuou em Sofala. A consulta das fichas e o retorno conservaram as escolhas pendentes, incluindo Fiação. |
|
||||
| Recuperar no dossier | Após **Rever dados antes de guardar → Guardar dados do investimento → Voltar ao dossier → recarregar**, recuperaram-se três fichas e quatro relações complementares: Silos/armazenagem, Fiação, Formação e Cadeia de frio, cada uma com a sua origem e localização informada ou indeterminada. O título deliberado do dossier permaneceu intacto. |
|
||||
|
||||
**Âmbito dos exemplos:** a ficha 286 e a origem da carteira são identidades já existentes em QA. A base QA não tinha ofertas de serviços; para a prova visual positiva foram preparados **dois projectos, duas oportunidades (309/310), uma relação complementar pública e um serviço (17)**, todos explicitamente marcados **SYNTHETIC QA**. O serviço foi tornado visível unicamente como fixture da base QA guardada, não como publicação real nem aprovação da Tempo; não foi transportado para a homologação. A prova não declara um serviço real disponível no destino. Os registos anteriores de oportunidades (45), projectos (17), fichas da carteira (30) e parcerias (zero) permaneceram iguais por hash na preparação das fixtures. Originais, relações, permissões e aceitações anteriores foram preservados.
|
||||
|
||||
Provas locais: [ficha existente](../../../outputs/complements-visual-20261023/existing-opportunity.png), [serviço e sua origem](../../../outputs/complements-visual-20261023/associated-service.png), [oportunidades recuperadas](../../../outputs/complements-visual-20261023/recovered-opportunities.png) e [complementos recuperados](../../../outputs/complements-visual-20261023/recovered-complements.png). Texto visível recuperado e recibo das fixtures no mesmo directório. Actualizado o [recibo existente](investment-complements-qa-receipt-20261023.json) e a cobertura das cinco linhas existentes da matriz. Não houve nova alteração funcional, migration, instalação, importação ou escrita na homologação nesta passagem; as suites automáticas anteriores não foram repetidas.
|
||||
|
||||
**Saldo preservado:** conferência visual positiva dos complementos descritos, incluindo origem na segunda oportunidade. Zonas/parques, Terra/DUAT, Balcão e manifestação com contextos específicos de várias oportunidades **continuam por demonstrar**. Não se declara aceitação da Tempo, disponibilização no destino ou fecho global. Os três fechos de `6b544548`/`4098192a` mantêm-se.
|
||||
|
||||
## Complementos ligados ao investimento e ao dossier — preparação QA, 23/10/2026
|
||||
|
||||
**Origem funcional:** pedido expresso da Tempo nesta retoma: ligar regadio, silos, frio, processamento, fiação e logística às oportunidades existentes; distinguir necessidades ainda sem oferta; ligar certificação, formação e financiamento aos serviços/instrumentos existentes, conservando contexto e identidade no dossier. Este incremento não reabre gravação/idioma, proveniência do relatório ou Beira → sete oportunidades, encerrados no âmbito de `6b544548`/`4098192a`. Mantêm-se as aceitações e os limites transversais anteriores.
|
||||
|
||||
**Causa delimitada no código anterior:** `content.focus` era uma string, validada com os restantes campos textuais em `InvestmentPortfolioController::save` e apresentada por `portfolio/show.tsx` como texto em `<dd>`. Não existia resolução desses complementos para identidades de oportunidades/serviços nem selecção complementar no contexto privado. O bloco separado de oportunidades relacionadas não conservava uma relação tipificada «origem → complemento escolhido». Isso demonstra a integração ausente nesse trecho, não eliminação de fichas ou perda de dados. A tabela `complementary_project_links` já existia; não se criou outra tabela ou cadastro paralelo.
|
||||
|
||||
**Comportamento preparado no percurso existente:**
|
||||
|
||||
- As fichas públicas da carteira e das oportunidades apresentam os complementos mencionados no seu contexto documental. O resolvedor `InvestmentComplements` cruza a menção com as relações existentes à carteira, as ligações públicas entre projectos complementares ou a cadeia de valor publicamente documentada. A menção isolada não associa oportunidades fora dessas relações. Mantêm-se os estados publicados da classificação, incluindo referências preliminares, sem os promover a confirmação.
|
||||
- Uma oportunidade identificada abre pela mesma rota/ID/slug; a escolha inclui origem, tipo de complemento, ficha de destino, localização escolhida, versão, trecho, fonte e hash do contexto consultado. Quando a componente está expressamente incluída na própria oportunidade, liga a essa identidade e explica-o, sem criar outra ficha.
|
||||
- Certificação/formação/financiamento consultam ofertas publicadas da Bolsa explicitamente ligadas à origem, distinguindo `offering` de `seeking`. Serviços ligados à ficha da carteira ou ao seu projecto/cluster também são reutilizados. Financiamento reutiliza o tipo financeiro existente; uma necessidade de financiamento no resumo de um projecto não é, por si, uma oferta. Não se activaram contratos modulares nem instrumentos por importação.
|
||||
- Sem oferta identificada nas relações desse contexto, apresenta-se **necessidade complementar**, com acção para iniciar uma ideia ou prepará-la num dossier existente. Não se cria nem publica uma oportunidade como efeito dessa acção. A ausência de correspondência neste recorte não declara inexistência absoluta no acervo.
|
||||
- No editor do dossier, os complementos são consultados por oportunidade e localização; um território conhecido incompatível exclui o destino, e localização desconhecida continua «por definir». As escolhas têm prévia e confirmação, rascunho, retirada explícita e recuperação. Ao consultar/seleccionar outro complemento, a navegação aguarda a gravação das edições pendentes; o contexto acompanha a consulta da ficha pública. Uma entrada antiga retirada não volta por actualização; uma nova selecção explícita pode recuperá-la.
|
||||
- A relação fica no payload cifrado e no histórico existente do dossier, aparece no dossier e no componente comum dos quatro formulários, e acompanha o snapshot do simulador e as linhas do modelo comum de relatório/exportação. Os dados deliberados do investimento principal conservam-se. Uma oferta retirada não pode entrar como nova escolha; uma relação anteriormente confirmada conserva o snapshot para revisão, sem republicar a oferta. Uma origem indisponível na consulta em lote é assinalada e não oculta os restantes resultados.
|
||||
|
||||
**Escolhas técnicas, sem novas exigências organizacionais:** vocabulário literal dos nove âmbitos solicitados para identificar trechos; IDs e relações existentes para resolver destinos; filtragem pela hierarquia territorial pública; selecção complementar no JSON cifrado e confirmação/versionamento já existentes. Não houve migration, novo papel, nova aprovação, importação, publicação, alteração de dados activos ou instalação na homologação. Não se transportaram dados QA para o destino.
|
||||
|
||||
**Verificação delimitada:** testes novos de `InvestmentComplementsTest` na base isolada `simulator_fiscal_delta_20261021_test`, porta 55441, com guarda do alvo e transacções revertidas; testes frontend novos em `investment-complements.test.mjs`; TypeScript e build. A prova da cadeia de valor usa um contrato público sintético identificado como tal, sem declarar novas relações reais no cadastro. Recibo resumido: [investment-complements-qa-receipt-20261023.json](investment-complements-qa-receipt-20261023.json). Não se repetiram os três casos aceites. Não se declara conferência visual nova no navegador, instalação ou aceitação da Tempo.
|
||||
|
||||
**Guia de utilização preparado:** abrir a ficha principal → **Investimentos e serviços complementares** → consultar a ficha correspondente ou **Iniciar preparação de uma ideia** → seleccionar o dossier autorizado ou criar a ideia → rever os complementos, localizações e diferenças → confirmar no editor. Para retirar, usar **Retirar esta relação da preparação** e confirmar a comparação. Os formulários ligados mostram a relação consultada, mantendo os seus próprios dados e operações de gravação/submissão.
|
||||
|
||||
**Saldo:** este incremento cobre a ligação e conservação dos complementos resolvidos pelas fontes/relações acima. Não declara associação automática de todos os complementos a todas as fichas, cadastro completo dos serviços ou cobertura de todas as expressões possíveis. As opções comuns de zonas/parques e a passagem de requisitos específicos de várias oportunidades aos restantes formulários, identificadas na avaliação anterior, continuam parciais. Matriz existente: RF-CLU-003, RF-COR-004, AUD-REL-02, AUD-LEG-06 e AUD-SIM-INT06, sem nova matriz/backlog.
|
||||
|
||||
## Avaliação dos trechos restantes — 23/10/2026
|
||||
|
||||
Decisão expressa da Tempo nesta retoma: **encerradas no âmbito reportado** gravação/idioma, origem dos dados no relatório e ligação da Beira às sete oportunidades; entrega `6b544548`, recibo `4098192a`. Reutilizar essas provas, sem repetir os casos aceites. **A aplicação transversal permanece parcial.** Esta passagem é avaliação estática do código e inventário de cobertura existente; não é nova prova visual, execução dos testes citados ou alteração na homologação. Não foi confirmada perda adicional de dados ou funcionalidades.
|
||||
|
||||
| Prioridade | Implementação e provas existentes a preservar | Limite encontrado / trecho ainda a demonstrar |
|
||||
| --- | --- | --- |
|
||||
| Várias oportunidades | `InvestmentOpportunityChoices`, `InvestorContext` e `investment-opportunity-choices.tsx` conservam território, actividades e origens por ficha. O teste existente `test_multiple_opportunities_keep_sites_sources_requirements_and_results_in_one_dossier` cobre confirmação, recuperação, requisitos agregados e resultados preservados. Não foi repetido. | `InvestorContext.php:384–386` escolhe a primeira oportunidade para os campos comuns. Avaliar retirada/mudança da primeira e alteração apenas da segunda, sem atribuir os dados comuns a todas as fichas. A existência do teste não demonstra a interface deste delta. |
|
||||
| Corredor/localização | Cada cartão tem corredor e caminho territorial próprios; a mudança conserva caminho compatível e retira níveis incompatíveis com explicação. O teste existente `test_corridor_change_keeps_compatible_paths_and_removes_only_incompatible_geography` preserva esta cobertura. | `context.tsx` carrega parques por `choices[0]`, e `InvestorContext` também usa a primeira escolha na compatibilidade comum. Uma mudança apenas na segunda não altera esses parâmetros. Falta demonstrar como se apresenta e distingue a aplicabilidade das zonas/parques nas várias oportunidades. Não inferir que todas perderam a localização. |
|
||||
| Reutilização nos restantes formulários | `DossierPrefill` está integrado em simulador, Terra/DUAT, balcão e manifestação. `inheritCompatibleFields` preenche vazios, acompanha herança identificada e conserva valores deliberados; a interface permite comparar/substituir campos. Balcão respeita os campos editáveis de um pedido devolvido. | `prefill()` entrega uma parcela territorial comum a Terra/DUAT e valores comuns aos outros destinos. No componente de reutilização, a lista `OpportunityChoices` só aparece para `simulator`. Não está demonstrada a passagem dos contextos específicos de duas oportunidades aos restantes formulários; a ligação ao mesmo dossier não comprova essa cobertura. |
|
||||
|
||||
Próxima prova delimitada em QA isolado: duas oportunidades com corredores/localizações distintos; mudar apenas a segunda e conferir opções/requisitos e conservação da primeira; retirar a primeira e comparar o impacto proposto nos campos comuns; abrir Terra/DUAT, balcão e manifestação a partir desse dossier, conferir dados comuns e referências específicas, preservar uma edição deliberada e recuperar os formulários. Usar apenas as etapas aplicáveis, sem manifestação/submissão real. Não repetir idioma/gravação, proveniência do relatório ou as sete ligações aceites. A comparação entre primeira e segunda escolha é um método técnico de avaliação, não uma nova regra de negócio nem decisão de escolher uma localização comum pela Tempo.
|
||||
|
||||
Cobertura registada nas linhas existentes AUD-SIM-INT03 e AUD-LEG-06 da matriz; sem nova matriz ou backlog. Nenhuma alteração de código funcional, schema, dados, configuração ou permissões nesta avaliação. Os limites acima permanecem pendentes de demonstração/correcção delimitada; não são declarados resolvidos por documentação.
|
||||
|
||||
## Instalação e conferência das três correcções — 23/10/2026
|
||||
|
||||
**Revisão servida: `6b544548`.** Conferência visual QA da Beira concluída antes da integração: sete oportunidades visíveis, Florestas abre com origem da carteira conservada nos dois botões de preparação. Integrada por avanço directo no Git, sem alterações concorrentes em main. Executor concluído às 13:45:19 UTC, 206/206 testes frontend; zero migrations/importações e nenhuma alteração de permissões. Recibo: `continuity-corrections-installation-receipt-20261023.json`. As referências abaixo a QA pendente/homologação em `7c1f5321` descrevem o estado histórico anterior, sucedido por esta instalação.
|
||||
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 9.4 MiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -9,220 +9,22 @@
|
||||
|
||||
@import './tempo-header.css';
|
||||
|
||||
/* Institutional shell adapted from Tempo; page content keeps its own theme. */
|
||||
.tempo-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background: #090909;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 84px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 140px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.tempo-nav-trigger:hover,
|
||||
.tempo-nav-trigger[data-state='open'] {
|
||||
background: #ffffff0d;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-nav-trigger[data-current-group] {
|
||||
color: #fff;
|
||||
box-shadow: inset 0 -2px #ed1c24;
|
||||
}
|
||||
.tempo-nav-trigger[data-state='open'] > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.tempo-nav-trigger {
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.tempo-nav-trigger > svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@keyframes portal-hero-caret {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.tempo-utility-button {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e2e8f0;
|
||||
|
||||
.portal-hero-caret {
|
||||
animation: portal-hero-caret 0.8s step-end infinite;
|
||||
}
|
||||
.tempo-utility-button:hover {
|
||||
background: #ffffff10;
|
||||
}
|
||||
.tempo-header-social {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--social-color);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
opacity 0.2s;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.tempo-header-social:hover {
|
||||
transform: translateY(-2px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.tempo-header-social {
|
||||
width: 26px;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.portal-hero-caret {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.tempo-login-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #ffffff50;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.tempo-login-button:hover {
|
||||
background: #ed1c24;
|
||||
border-color: #ed1c24;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
order: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ed1c24;
|
||||
border-radius: 0.4rem;
|
||||
background: #ed1c24;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tempo-interest-button:hover {
|
||||
background: #c9151c;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
order: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding-block: 0.5rem;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tempo-panel-summary a {
|
||||
color: #fff;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.tempo-panel-summary a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tempo-panel-summary span {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tempo-header-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding-block: 0.65rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 90px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 96px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 152px;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
order: 6;
|
||||
margin-left: 0;
|
||||
}
|
||||
.tempo-header-inner .tempo-nav-trigger {
|
||||
padding-inline: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
order: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: auto;
|
||||
min-width: 102px;
|
||||
border-top: 0;
|
||||
border-left: 1px solid #ffffff1a;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1440px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 102px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 168px;
|
||||
}
|
||||
.tempo-header-inner .tempo-nav-trigger {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.tempo-header :is(a, button):focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.tempo-dropdown {
|
||||
z-index: 60;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/investor-context';
|
||||
import { inheritCompatibleFields } from '@/lib/context-inheritance';
|
||||
import OpportunityChoices from './investment-opportunity-choices';
|
||||
import { SelectedComplements } from './investment-complements';
|
||||
type Props = {
|
||||
contextId?: string | null;
|
||||
consumer: 'simulator' | 'land' | 'desk' | 'submission';
|
||||
@@ -145,6 +146,7 @@ export function DossierPrefill(props: Props) {
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
{consumer === 'simulator' && !!view.context.opportunity_choices?.length && <OpportunityChoices choices={view.context.opportunity_choices}/>}
|
||||
<SelectedComplements items={view.context.complements ?? []}/>
|
||||
{Object.entries(view.context.selection)
|
||||
.filter(([slot]) => consumer !== 'simulator' || !view.context.opportunity_choices?.length || !['opportunity', 'territory'].includes(slot))
|
||||
.map(([, r]) =>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { complementPreparationHref } from '@/lib/search-territory';
|
||||
import { useState } from 'react';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
|
||||
export function ComplementEntryActions({target}: {target: string}) {
|
||||
const {url} = usePage();
|
||||
const href = complementPreparationHref(url,target);
|
||||
return href ? <Link className="block rounded border p-3 underline" href={href}>Seleccionar este complemento e continuar no dossier de origem</Link> : null;
|
||||
}
|
||||
|
||||
type Offer = {key: string; title: string; href: string; location?: string};
|
||||
export type Complement = {
|
||||
key: string; code: string; title: string; kind: 'opportunity' | 'service' | 'need';
|
||||
origin: Offer; target: Offer | null; territory_id: string | null;
|
||||
territory?: {title: string} | null;
|
||||
source: string; excerpt: string; source_sha256: string;
|
||||
availability?: 'review_required';
|
||||
};
|
||||
export type ComplementOption = Omit<Complement, 'key' | 'kind' | 'target'> & {opportunities: Offer[]; services: Offer[]};
|
||||
export const complementInput = (item: Complement) => ({origin: item.origin.key, code: item.code, target: item.target?.key ?? null, territory_id: item.territory_id});
|
||||
|
||||
export function SelectedComplements({items, onRemove}: {items: Complement[]; onRemove?: (key: string) => void}) {
|
||||
if (!items.length) return null;
|
||||
return <section className="space-y-3 rounded-xl border p-4" aria-label="Complementos escolhidos">
|
||||
<h2 className="text-xl font-bold">Complementos escolhidos no dossier</h2>
|
||||
{items.map(item => <article key={item.key} className="space-y-1">
|
||||
<h3 className="font-semibold">{item.title} · {item.kind === 'need' ? 'Necessidade complementar — ideia em preparação' : item.kind === 'service' ? 'Serviço ou instrumento' : 'Oportunidade cadastrada'}</h3>
|
||||
<p>Investimento de origem: <Link className="underline" href={item.origin.href}>{item.origin.title}</Link></p>
|
||||
<p>Localização da preparação: {item.territory?.title ?? 'Por definir'}</p>
|
||||
{item.target && <Link className="block underline" href={item.target.href}>Abrir ficha: {item.target.title}</Link>}
|
||||
{item.availability === 'review_required' && <p role="status">A oferta ou a referência mudou. Relação anterior conservada para revisão; consulte as opções actuais antes de a utilizar.</p>}
|
||||
<p className="text-sm">Fonte: {item.source} · Trecho: {item.excerpt}</p>
|
||||
{onRemove && <button type="button" className="underline" onClick={() => onRemove(item.key)}>Retirar esta relação da preparação</button>}
|
||||
</article>)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function InvestmentComplements({items, dossierId, onNavigate}: {items: ComplementOption[]; dossierId?: string; onNavigate?: (href: string) => void}) {
|
||||
const [selectionKey] = useState(createOperationKey);
|
||||
if (!items.length) return null;
|
||||
const prepare = (item: ComplementOption, target?: Offer) => {
|
||||
const query = new URLSearchParams({complement_origin: item.origin.key, complement_code: item.code});
|
||||
if (target) query.set('complement_target', target.key);
|
||||
if (item.territory_id) query.set('complement_territory', item.territory_id);
|
||||
if (dossierId) query.set('selection_key', selectionKey);
|
||||
return (dossierId ? `/investimentos/${dossierId}/contexto/editar` : '/investimentos/iniciar')+'?'+query;
|
||||
};
|
||||
const consult = (item: ComplementOption, offer: Offer) => {
|
||||
const query = prepare(item,offer).split('?')[1];
|
||||
return offer.href+'?'+query+(dossierId ? '&investment_dossier='+encodeURIComponent(dossierId) : '');
|
||||
};
|
||||
return <section className="space-y-4 rounded-xl border p-4" aria-label="Investimentos e serviços complementares">
|
||||
<h2 className="text-xl font-bold">Investimentos e serviços complementares</h2>
|
||||
<p>Escolha os complementos relevantes para o seu investimento. A localização e as condições de cada ficha conservam-se.</p>
|
||||
{items.map(item => <article className="space-y-2 border-t pt-3" key={item.origin.key+item.code+(item.territory_id ?? '')}>
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<p>Investimento de origem: {item.origin.title} · {item.territory?.title ?? 'Localização por definir'}</p>
|
||||
<p className="text-sm">Referência: {item.source} · Trecho: {item.excerpt}</p>
|
||||
{[...item.opportunities, ...item.services].map(offer => <div key={offer.key} className="space-y-1">
|
||||
<p><Link className="underline" href={consult(item,offer)} onClick={event => {if(onNavigate){event.preventDefault();onNavigate(consult(item,offer));}}}>{offer.title}</Link> · {offer.location}</p>
|
||||
<Link className="block underline" href={prepare(item, offer)} onClick={event => {if(onNavigate){event.preventDefault();onNavigate(prepare(item,offer));}}}>Seleccionar esta {offer.key.startsWith('opportunity:') ? 'oportunidade' : 'oferta'} para o dossier</Link>
|
||||
</div>)}
|
||||
{!item.opportunities.length && !item.services.length && <>
|
||||
<p>Necessidade complementar referida; sem oferta cadastrada identificada nas relações deste contexto.</p>
|
||||
<Link className="underline" href={prepare(item)} onClick={event => {if(onNavigate){event.preventDefault();onNavigate(prepare(item));}}}>Iniciar preparação de uma ideia de {item.title.toLocaleLowerCase('pt-MZ')}</Link>
|
||||
</>}
|
||||
</article>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
HOME_SECTION,
|
||||
HOME_SECTION_HEADING,
|
||||
PORTAL_MORE_LINK,
|
||||
} from '@/lib/portal-presentation';
|
||||
|
||||
const contexts = [
|
||||
{
|
||||
key: 'clusters',
|
||||
href: '/clusters',
|
||||
title: 'Clusters',
|
||||
description: 'Cadeias de valor e lacunas produtivas.',
|
||||
image: 'clusters-agroindustria',
|
||||
alt: 'Triagem de produtos agrícolas numa unidade de transformação com área de cultivo ao fundo.',
|
||||
},
|
||||
{
|
||||
key: 'corredores',
|
||||
href: '/corredores',
|
||||
title: 'Corredores',
|
||||
description: 'Territórios, logística e conexões.',
|
||||
image: 'corredores-logisticos-v2',
|
||||
alt: 'Terminal de mercadorias junto à água, com contentores, uma grua e vias de acesso.',
|
||||
},
|
||||
{
|
||||
key: 'projectos',
|
||||
href: '/projectos-ancora',
|
||||
title: 'Projectos âncora',
|
||||
description: 'Projectos estruturantes e relações.',
|
||||
image: 'projectos-ancora',
|
||||
alt: 'Equipa de engenharia acompanha o planeamento de uma instalação de energia solar.',
|
||||
},
|
||||
{
|
||||
key: 'educacao',
|
||||
href: '/educacao',
|
||||
title: 'Educação',
|
||||
description: 'Investimentos em competências e formação.',
|
||||
image: 'educacao-formacao',
|
||||
alt: 'Jovens desenvolvem competências de electrónica e informática numa aula de formação técnica.',
|
||||
},
|
||||
{
|
||||
key: 'documentos',
|
||||
href: '/documentos',
|
||||
title: 'Documentos e Formulários',
|
||||
description:
|
||||
'Legislação, procedimentos, formulários e materiais de apoio.',
|
||||
image: 'documentos-formularios',
|
||||
alt: 'Profissional analisa documentação empresarial organizada numa secretária de escritório.',
|
||||
},
|
||||
];
|
||||
|
||||
function ContextImage({
|
||||
image,
|
||||
alt,
|
||||
preview,
|
||||
}: {
|
||||
image: string;
|
||||
alt: string;
|
||||
preview: boolean;
|
||||
}) {
|
||||
const [fallback, setFallback] = useState(0);
|
||||
const base = `/images/investment-context/${image}`;
|
||||
|
||||
return (
|
||||
<div className="aspect-video w-full shrink-0 overflow-hidden bg-slate-100">
|
||||
{fallback < 2 ? (
|
||||
<img
|
||||
src={`${base}${fallback ? '-fallback' : ''}.webp`}
|
||||
srcSet={
|
||||
fallback
|
||||
? undefined
|
||||
: `${base}-480.webp 480w, ${base}.webp 960w`
|
||||
}
|
||||
sizes={
|
||||
preview
|
||||
? '(min-width: 1152px) 266px, (min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw'
|
||||
: '(min-width: 1152px) 357px, (min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw'
|
||||
}
|
||||
alt={alt}
|
||||
width={960}
|
||||
height={540}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-center"
|
||||
onError={() =>
|
||||
setFallback((current) => Math.min(current + 1, 2))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
className="flex h-full items-center justify-center px-5 text-center text-sm text-slate-600"
|
||||
>
|
||||
Imagem indisponível
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestmentContextGrid({
|
||||
preview = false,
|
||||
}: {
|
||||
preview?: boolean;
|
||||
}) {
|
||||
const visibleContexts = preview ? contexts.slice(0, 4) : contexts;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Contextos de investimento"
|
||||
className={
|
||||
preview
|
||||
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-4'
|
||||
: 'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'
|
||||
}
|
||||
>
|
||||
{visibleContexts.map((context) => (
|
||||
<a
|
||||
key={context.key}
|
||||
href={context.href}
|
||||
aria-labelledby={`context-${context.key}-title`}
|
||||
aria-describedby={`context-${context.key}-description`}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-colors hover:border-blue-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-700"
|
||||
>
|
||||
<ContextImage
|
||||
image={context.image}
|
||||
alt={context.alt}
|
||||
preview={preview}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-4">
|
||||
<h3
|
||||
id={`context-${context.key}-title`}
|
||||
className="text-lg leading-6 font-bold text-slate-900"
|
||||
>
|
||||
{context.title}
|
||||
</h3>
|
||||
<p
|
||||
id={`context-${context.key}-description`}
|
||||
className="text-sm leading-5 text-slate-600"
|
||||
>
|
||||
{context.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvestmentContextCards() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="investment-context-heading"
|
||||
className={`${HOME_SECTION} space-y-4`}
|
||||
>
|
||||
<h2
|
||||
id="investment-context-heading"
|
||||
className={HOME_SECTION_HEADING}
|
||||
>
|
||||
Encontre o contexto do seu investimento
|
||||
</h2>
|
||||
<InvestmentContextGrid preview />
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href="/contextos-de-investimento"
|
||||
className={PORTAL_MORE_LINK}
|
||||
>
|
||||
Ver mais <ArrowRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { HOME_SECTION, HOME_SECTION_HEADING } from '@/lib/portal-presentation';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: 'explorar',
|
||||
title: '1. Explorar',
|
||||
description: 'Identifique o sector, território e oportunidade.',
|
||||
href: '/oportunidades',
|
||||
alt: 'Dois profissionais avaliam uma oportunidade de investimento no terreno com apoio de um tablet.',
|
||||
},
|
||||
{
|
||||
key: 'preparar',
|
||||
title: '2. Preparar',
|
||||
description: 'Conheça o percurso e organize os documentos.',
|
||||
href: '/como-investir',
|
||||
alt: 'Empreendedora e consultor analisam documentos e organizam pastas numa reunião de preparação.',
|
||||
},
|
||||
{
|
||||
key: 'acompanhar',
|
||||
title: '3. Acompanhar',
|
||||
description: 'Envie o pedido e acompanhe o protocolo.',
|
||||
href: '/balcao-virtual',
|
||||
alt: 'Empreendedor consulta um pedido no computador portátil numa secretária de escritório.',
|
||||
},
|
||||
];
|
||||
|
||||
function StepImage({ name, alt }: { name: string; alt: string }) {
|
||||
const [fallback, setFallback] = useState(0);
|
||||
const base = `/images/investment-steps/${name}`;
|
||||
|
||||
return (
|
||||
<div className="aspect-video w-full shrink-0 overflow-hidden bg-slate-100">
|
||||
{fallback < 2 ? (
|
||||
<img
|
||||
src={`${base}${fallback ? '-fallback' : ''}.webp`}
|
||||
srcSet={
|
||||
fallback
|
||||
? undefined
|
||||
: `${base}-480.webp 480w, ${base}.webp 960w`
|
||||
}
|
||||
sizes="(min-width: 1152px) 357px, (min-width: 768px) 33vw, 100vw"
|
||||
alt={alt}
|
||||
width={960}
|
||||
height={540}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-center"
|
||||
onError={() =>
|
||||
setFallback((current) => Math.min(current + 1, 2))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600"
|
||||
>
|
||||
Imagem indisponível
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvestmentSteps() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="investment-steps-heading"
|
||||
className={`${HOME_SECTION} space-y-4`}
|
||||
>
|
||||
<h2 id="investment-steps-heading" className={HOME_SECTION_HEADING}>
|
||||
Como Investir
|
||||
</h2>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{steps.map((step) => (
|
||||
<a
|
||||
key={step.key}
|
||||
href={step.href}
|
||||
aria-labelledby={`step-${step.key}-title`}
|
||||
aria-describedby={`step-${step.key}-description`}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-colors hover:border-[#ed1c24] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
<StepImage name={step.key} alt={step.alt} />
|
||||
<div className="space-y-1.5 p-4">
|
||||
<h3
|
||||
id={`step-${step.key}-title`}
|
||||
className="text-lg leading-6 font-bold text-slate-900"
|
||||
>
|
||||
{step.title}
|
||||
</h3>
|
||||
<p
|
||||
id={`step-${step.key}-description`}
|
||||
className="text-sm leading-5 text-slate-600"
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function PortalGroupedMenu() {
|
||||
legacyContentEnabled?: boolean;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
scopedAdministration?: {label:string;href:string}[];
|
||||
scopedAdministration?: { label: string; href: string }[];
|
||||
}>();
|
||||
const { t, language, requestCount } = useTempoUi();
|
||||
const mobile = useIsMobile();
|
||||
@@ -73,7 +73,16 @@ export function PortalGroupedMenu() {
|
||||
opportunityManagement: props.opportunityManagement,
|
||||
profileAccess: props.profileAccess,
|
||||
});
|
||||
if (props.scopedAdministration?.length) groups.push({id:'scoped-administration',label:'Administração',children:props.scopedAdministration.map((item,index)=>({id:`scoped-${index}`,label:item.label,href:item.href}))});
|
||||
if (props.scopedAdministration?.length)
|
||||
groups.push({
|
||||
id: 'scoped-administration',
|
||||
label: 'Administração',
|
||||
children: props.scopedAdministration.map((item, index) => ({
|
||||
id: `scoped-${index}`,
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
})),
|
||||
});
|
||||
const current = currentNavigationGroup(groups, url);
|
||||
const currentItem = currentNavigationItem(groups, url);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
@@ -249,7 +258,7 @@ export function PortalGroupedMenu() {
|
||||
institutionalCurrent || undefined
|
||||
}
|
||||
>
|
||||
Tempo
|
||||
Mais
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { ArrowRight, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { HomeResponse } from '@/lib/portal-api';
|
||||
|
||||
const slides = [
|
||||
{
|
||||
image: '/images/hero/hero-1.webp',
|
||||
mobile: '/images/hero/hero-1-mobile.webp',
|
||||
},
|
||||
{
|
||||
image: '/images/hero/hero-3.webp',
|
||||
mobile: '/images/hero/hero-3-mobile.webp',
|
||||
},
|
||||
];
|
||||
|
||||
const heroTitle = 'INVISTA EM MOÇAMBIQUE';
|
||||
const heroSubtitle =
|
||||
'Transforme informação em decisões e ideias em negócios reais.';
|
||||
const typingStart = 250;
|
||||
const titleStep = 50;
|
||||
const subtitleStep = 28;
|
||||
const subtitleStart = typingStart + heroTitle.length * titleStep + 150;
|
||||
const typingDuration = subtitleStart + heroSubtitle.length * subtitleStep;
|
||||
|
||||
function TypewriterLine({
|
||||
text,
|
||||
visibleText,
|
||||
caret = false,
|
||||
running,
|
||||
}: {
|
||||
text: string;
|
||||
visibleText: string;
|
||||
caret?: boolean;
|
||||
running: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="grid">
|
||||
<span className="sr-only">{text}</span>
|
||||
{/* Reserve the final wrapping before the first character appears. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="invisible col-start-1 row-start-1"
|
||||
>
|
||||
{text}
|
||||
{caret && <span className="ml-1 inline-block w-0 border-l-2" />}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-hero-typed
|
||||
className="col-start-1 row-start-1"
|
||||
>
|
||||
{visibleText}
|
||||
{caret && (
|
||||
<span
|
||||
className="portal-hero-caret ml-1 inline-block h-[0.85em] w-0 border-l-2 border-white"
|
||||
style={{
|
||||
animationPlayState: running ? 'running' : 'paused',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroAnimatedCopy({
|
||||
reducedMotion,
|
||||
running,
|
||||
}: {
|
||||
reducedMotion: boolean;
|
||||
running: boolean;
|
||||
}) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const complete = elapsed >= typingDuration;
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !running || complete) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsed((current) => Math.min(current + 25, typingDuration));
|
||||
}, 25);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reducedMotion, running, complete]);
|
||||
|
||||
const titleLength = Math.max(
|
||||
0,
|
||||
Math.floor((elapsed - typingStart) / titleStep),
|
||||
);
|
||||
const subtitleLength = Math.max(
|
||||
0,
|
||||
Math.floor((elapsed - subtitleStart) / subtitleStep),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1
|
||||
id="portal-hero-title"
|
||||
className="max-w-5xl text-3xl leading-tight font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
<TypewriterLine
|
||||
text={heroTitle}
|
||||
visibleText={
|
||||
reducedMotion
|
||||
? heroTitle
|
||||
: heroTitle.slice(0, titleLength)
|
||||
}
|
||||
caret={!reducedMotion}
|
||||
running={running}
|
||||
/>
|
||||
</h1>
|
||||
<p className="mt-5 max-w-3xl text-lg leading-relaxed font-medium text-white sm:text-2xl">
|
||||
<TypewriterLine
|
||||
text={heroSubtitle}
|
||||
visibleText={
|
||||
reducedMotion
|
||||
? heroSubtitle
|
||||
: heroSubtitle.slice(0, subtitleLength)
|
||||
}
|
||||
running={running}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type PortalHeroProps = {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onSearch: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onNavigate: (view: 'opportunities') => void;
|
||||
stats?: HomeResponse['data']['stats'];
|
||||
};
|
||||
|
||||
/** Reference visuals adapted to the V2 data and navigation contracts. */
|
||||
export default function PortalHero({
|
||||
query,
|
||||
setQuery,
|
||||
onSearch,
|
||||
onNavigate,
|
||||
stats,
|
||||
}: PortalHeroProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [interacting, setInteracting] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [pageVisible, setPageVisible] = useState(true);
|
||||
// Start static for SSR and until the browser's motion preference is known.
|
||||
const [reducedMotion, setReducedMotion] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const preference = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
);
|
||||
const updateMotion = () => setReducedMotion(preference.matches);
|
||||
const updateVisibility = () => setPageVisible(!document.hidden);
|
||||
updateMotion();
|
||||
updateVisibility();
|
||||
preference.addEventListener('change', updateMotion);
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => {
|
||||
preference.removeEventListener('change', updateMotion);
|
||||
document.removeEventListener('visibilitychange', updateVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || interacting || hovering || !pageVisible) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setCurrentSlide((current) => (current + 1) % slides.length);
|
||||
}, 8000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reducedMotion, interacting, hovering, pageVisible]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="portal-hero-title"
|
||||
className="portal-hero relative isolate overflow-hidden bg-[#101820] text-white"
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onFocusCapture={() => setInteracting(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget))
|
||||
setInteracting(false);
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 -z-20" aria-hidden="true">
|
||||
{slides.map((slide, index) => (
|
||||
<picture key={slide.image}>
|
||||
<source
|
||||
media="(max-width: 640px)"
|
||||
srcSet={slide.mobile}
|
||||
/>
|
||||
<img
|
||||
src={slide.image}
|
||||
alt=""
|
||||
width={1920}
|
||||
height={1280}
|
||||
fetchPriority={index === 0 ? 'high' : 'low'}
|
||||
decoding="async"
|
||||
className={`portal-hero-image absolute inset-0 h-full w-full object-cover transition-opacity duration-1500 motion-reduce:transition-none ${currentSlide === index ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</picture>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-linear-to-r from-black/75 via-black/65 to-black/60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 -z-10 h-48 bg-linear-to-t from-black/65 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="mx-auto flex min-h-[88svh] max-w-6xl flex-col items-center justify-center px-5 py-12 text-center sm:px-6 sm:py-16 lg:py-20">
|
||||
<HeroAnimatedCopy
|
||||
key={currentSlide}
|
||||
reducedMotion={reducedMotion}
|
||||
running={pageVisible}
|
||||
/>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-white/90 sm:text-base">
|
||||
Oportunidades reais de investimento em Moçambique,
|
||||
organizadas por sector.
|
||||
</p>
|
||||
<p
|
||||
id="portal-hero-description"
|
||||
className="mt-2 max-w-2xl text-sm leading-relaxed text-white/80 sm:text-base"
|
||||
>
|
||||
Consulte projectos por província e estágio de maturidade,
|
||||
compreenda os requisitos legais e fiscais, e manifeste
|
||||
interesse directamente junto da entidade promotora.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={onSearch}
|
||||
role="search"
|
||||
aria-label="Pesquisar oportunidades"
|
||||
className="mt-7 w-full max-w-4xl"
|
||||
>
|
||||
<label
|
||||
htmlFor="portal-opportunity-search"
|
||||
className="mb-2 block text-left text-sm font-medium text-white/90"
|
||||
>
|
||||
Pesquisa rápida de oportunidades
|
||||
</label>
|
||||
<div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-white/30 bg-white p-1.5 shadow-2xl shadow-black/20 focus-within:ring-2 focus-within:ring-white focus-within:ring-offset-4 focus-within:ring-offset-[#101820] min-[400px]:flex-row">
|
||||
<input
|
||||
id="portal-opportunity-search"
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Ex.: agronegócio, Nampula, energia..."
|
||||
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex min-h-12 shrink-0 items-center justify-center gap-2 rounded-xl bg-[#ed1c24] px-5 text-sm font-semibold text-white transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
Pesquisar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => onNavigate('opportunities')}
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-white px-5 py-3 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
|
||||
>
|
||||
Consultar oportunidades{' '}
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<a
|
||||
href="/simulador-fiscal"
|
||||
className="inline-flex min-h-11 items-center rounded-xl border border-white/50 bg-black/15 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
|
||||
>
|
||||
Simulador Fiscal
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
aria-label="Indicadores do portal"
|
||||
className="mt-9 grid w-full max-w-lg grid-cols-3 gap-3 border-t border-white/25 pt-5"
|
||||
>
|
||||
{[
|
||||
['oportunidades', stats?.opportunities],
|
||||
['sectores', stats?.sectors],
|
||||
['províncias', stats?.provinces],
|
||||
].map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-col-reverse gap-1"
|
||||
>
|
||||
<dt className="text-xs text-white/85 sm:text-sm">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-2xl font-semibold text-white tabular-nums sm:text-3xl">
|
||||
{value ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
HOME_SECTION,
|
||||
HOME_SECTION_HEADING,
|
||||
PORTAL_MORE_LINK,
|
||||
} from '@/lib/portal-presentation';
|
||||
|
||||
export type PublicSector = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
opportunitiesCount: number;
|
||||
url?: string;
|
||||
specializations?: { id: number; name: string; url: string }[];
|
||||
};
|
||||
|
||||
const images: Record<string, string> = {
|
||||
agronegocio: 'agronegocio',
|
||||
turismo: 'turismo',
|
||||
infraestruturas: 'infraestruturas',
|
||||
'economia-azul': 'economia-azul',
|
||||
'mercado-financeiro': 'servicos-financeiros',
|
||||
'servicos-financeiros': 'servicos-financeiros',
|
||||
fintech: 'tic-economia-digital',
|
||||
mineracao: 'mineracao',
|
||||
energia: 'energia',
|
||||
'energia-especifica': 'energia',
|
||||
imobiliario: 'outros-sectores',
|
||||
'bens-de-consumo-e-franquias': 'industria',
|
||||
'bens-de-consumo-franquias': 'industria',
|
||||
'tecnologias-de-informacao': 'tic-economia-digital',
|
||||
'tic-economia-digital': 'tic-economia-digital',
|
||||
industria: 'industria',
|
||||
'logistica-transportes': 'logistica-transportes',
|
||||
};
|
||||
|
||||
const imageDescriptions: Record<string, string> = {
|
||||
agronegocio: 'Campos agrícolas e instalações de transformação de produtos.',
|
||||
turismo: 'Alojamento turístico junto à costa e à vegetação tropical.',
|
||||
infraestruturas: 'Ponte rodoviária sobre um rio, ligando áreas urbanas.',
|
||||
'economia-azul': 'Actividades de pesca e aquacultura numa zona costeira.',
|
||||
'servicos-financeiros':
|
||||
'Edifícios empresariais e serviços num espaço urbano.',
|
||||
mineracao:
|
||||
'Exploração mineira a céu aberto com instalações de processamento.',
|
||||
energia:
|
||||
'Painéis solares e infra-estruturas de transporte de electricidade.',
|
||||
industria:
|
||||
'Unidade industrial com área de armazenagem e movimentação de mercadorias.',
|
||||
'tic-economia-digital':
|
||||
'Infra-estruturas de telecomunicações num ambiente urbano.',
|
||||
'logistica-transportes':
|
||||
'Terminal portuário com contentores e transporte ferroviário.',
|
||||
'outros-sectores': 'Espaço empresarial e edifícios urbanos ao fundo.',
|
||||
};
|
||||
|
||||
function SectorImage({ slug, preview }: { slug: string; preview: boolean }) {
|
||||
const [failed, setFailed] = useState(0);
|
||||
const image = failed
|
||||
? 'outros-sectores'
|
||||
: (images[slug] ?? 'outros-sectores');
|
||||
const base = `/images/sectors/${image}`;
|
||||
if (failed > 1) return <span className="absolute inset-0 bg-slate-800" />;
|
||||
return (
|
||||
<img
|
||||
src={`${base}.webp`}
|
||||
srcSet={`${base}-480.webp 480w, ${base}.webp 960w`}
|
||||
sizes={
|
||||
preview
|
||||
? '(min-width: 1152px) 266px, (min-width: 1024px) 25vw, 50vw'
|
||||
: '(min-width: 1152px) 357px, (min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw'
|
||||
}
|
||||
width={960}
|
||||
height={540}
|
||||
alt={imageDescriptions[image]}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-105"
|
||||
onError={() => setFailed((current) => Math.min(current + 1, 2))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicSectorCards({
|
||||
sectors,
|
||||
preview = false,
|
||||
}: {
|
||||
sectors: PublicSector[];
|
||||
preview?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
preview
|
||||
? 'grid grid-cols-2 gap-4 lg:grid-cols-4'
|
||||
: 'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'
|
||||
}
|
||||
>
|
||||
{sectors.map((sector) => {
|
||||
const href =
|
||||
sector.url ??
|
||||
`/sectores/${sector.slug.split('/').map(encodeURIComponent).join('/')}`;
|
||||
const titleId = `public-sector-${sector.slug.replaceAll('/', '-')}`;
|
||||
return (
|
||||
<article
|
||||
key={sector.slug}
|
||||
className="group flex min-w-0 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-colors hover:border-[#ed1c24]"
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
aria-labelledby={titleId}
|
||||
className="relative block h-40 shrink-0 overflow-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#ed1c24] sm:h-44"
|
||||
>
|
||||
<SectorImage slug={sector.slug} preview={preview} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-linear-to-t from-black/85 via-black/20 to-transparent"
|
||||
/>
|
||||
<h3
|
||||
id={titleId}
|
||||
className="absolute right-3 bottom-3 left-3 text-sm leading-5 font-bold wrap-break-word text-white drop-shadow-lg min-[375px]:text-base sm:text-lg sm:leading-6"
|
||||
>
|
||||
{sector.name}
|
||||
</h3>
|
||||
</a>
|
||||
<div className="flex flex-1 flex-col gap-2 p-3 sm:p-4">
|
||||
{sector.description && (
|
||||
<p className="line-clamp-3 text-sm leading-5 wrap-break-word text-slate-600">
|
||||
{sector.description}
|
||||
</p>
|
||||
)}
|
||||
{sector.specializations?.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={child.url}
|
||||
className="text-xs font-semibold text-slate-700 underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
Especialização:{' '}
|
||||
{child.name.replace(
|
||||
'Mercado Financeiro — ',
|
||||
'',
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
<div className="mt-auto pt-1">
|
||||
<p className="text-xs leading-5 text-slate-500">
|
||||
{sector.opportunitiesCount} oportunidade
|
||||
{sector.opportunitiesCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
<a
|
||||
href={href}
|
||||
aria-label={`Ler mais sobre ${sector.name}`}
|
||||
className="inline-flex min-h-11 items-center gap-1.5 text-xs font-bold text-[#ed1c24] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
Ler mais{' '}
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageSectors({ sectors }: { sectors: PublicSector[] }) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="home-sectors-heading"
|
||||
className={HOME_SECTION}
|
||||
>
|
||||
<h2 id="home-sectors-heading" className={HOME_SECTION_HEADING}>
|
||||
Sectores de investimento
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">
|
||||
Explore as áreas prioritárias do programa de investimento
|
||||
nacional.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<PublicSectorCards sectors={sectors.slice(0, 4)} preview />
|
||||
{!sectors.length && (
|
||||
<p className="text-slate-600">
|
||||
Não existem sectores publicados.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<a href="/sectores" className={PORTAL_MORE_LINK}>
|
||||
Ver mais sectores{' '}
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +135,6 @@ export function TempoFooter() {
|
||||
<span className="text-[#ed1c24]">Tempo</span>.{' '}
|
||||
{t('Todos os direitos reservados.')}
|
||||
</p>
|
||||
<p>{t('Número de registo')}: 13/GABINFO-DE/: 2003</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -25,6 +25,7 @@ export const portalLinks = [
|
||||
['/apoio', 'Apoio ao Investidor'],
|
||||
] as const;
|
||||
const names: Record<string, string> = {
|
||||
'/contextos-de-investimento': 'Contextos de investimento',
|
||||
'/sobre': 'Sobre nós',
|
||||
'/contacto': 'Contacto',
|
||||
'/politica-de-privacidade': 'Política de Privacidade',
|
||||
@@ -176,11 +177,11 @@ export default function PortalNavigation({
|
||||
</div>
|
||||
<aside
|
||||
aria-label="Contacto de apoio ao investidor"
|
||||
className={printable ? 'print:hidden' : undefined}
|
||||
className={`mx-auto flex max-w-6xl justify-center px-5 pt-4 ${printable ? 'print:hidden' : ''}`}
|
||||
>
|
||||
<a
|
||||
href="/apoio"
|
||||
className="mx-3 mt-4 block max-w-[calc(100%-1.5rem)] rounded-xl bg-emerald-800 px-5 py-3 text-center text-sm font-semibold text-white shadow-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600"
|
||||
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-lg bg-[#ed1c24] px-4 py-2 text-center text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
Contactar Apoio ao Investidor
|
||||
</a>
|
||||
|
||||
@@ -56,6 +56,7 @@ export type OpportunityChoice = {
|
||||
source_sha256?: string | null;
|
||||
};
|
||||
export type DossierContext = {
|
||||
complements?: import('@/components/investment-complements').Complement[];
|
||||
requirements_assessment?: RequirementsAssessment;
|
||||
opportunity_choices?: OpportunityChoice[];
|
||||
revision: number;
|
||||
@@ -98,6 +99,7 @@ export type ContextView = {
|
||||
notice: string;
|
||||
};
|
||||
export type ContextPlan = {
|
||||
complements?: import('@/components/investment-complements').Complement[];
|
||||
requirements_assessment?: RequirementsAssessment;
|
||||
opportunity_choices?: OpportunityChoice[];
|
||||
private_investment?: { before: Preparation; after: Preparation } | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type Sector = {
|
||||
url?: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
specializations?: { id: number; name: string; slug: string; url: string }[];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const HOME_SECTION_HEADING =
|
||||
'text-2xl leading-8 font-bold text-slate-900';
|
||||
|
||||
export const HOME_SECTION = 'mx-auto max-w-6xl px-5 py-5 sm:py-6';
|
||||
|
||||
export const HOME_SECTION_BAND =
|
||||
'border-t border-slate-200 bg-white py-5 sm:py-6';
|
||||
|
||||
export const PORTAL_MORE_LINK =
|
||||
'inline-flex min-h-11 items-center justify-center gap-1.5 rounded-full border border-[#ed1c24] bg-white px-5 py-2 text-xs font-bold text-[#ed1c24] transition-colors hover:bg-[#ed1c24] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]';
|
||||
@@ -691,6 +691,11 @@ export function buildProjectDossierRows(
|
||||
if (draft.context_applicability_snapshot) {
|
||||
try {
|
||||
const context = JSON.parse(String(draft.context_applicability_snapshot));
|
||||
if (context.version === 1 && Array.isArray(context.complements)) for (const item of context.complements) {
|
||||
if (typeof item?.title !== 'string' || typeof item?.origin?.title !== 'string') continue;
|
||||
rows.push(['Localização', 'Complemento escolhido', item.title, `Origem: ${item.origin.title}; ${item.kind === 'need' ? 'necessidade complementar em preparação' : item.kind === 'service' ? 'serviço/instrumento' : 'oportunidade cadastrada'}`]);
|
||||
if (typeof item.target?.title === 'string') rows.push(['Localização', 'Ficha complementar', item.target.title, item.target.href ?? 'Referência guardada']);
|
||||
}
|
||||
if (context.version === 1 && Array.isArray(context.opportunity_choices)) for (const choice of context.opportunity_choices) {
|
||||
if (typeof choice?.opportunity?.title !== 'string') continue;
|
||||
rows.push(['Localização', 'Oportunidade escolhida', choice.opportunity.title, 'Referência escolhida no mesmo dossier']);
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
const levels = ['province_id', 'district_id', 'admin_post_id', 'locality_id'];
|
||||
const searchRoutes = ['/carteira', '/oportunidades', '/zonas', '/educacao'];
|
||||
|
||||
/** Preserve an explicit complementary selection while consulting its canonical public fiche. */
|
||||
export function complementPreparationHref(pageUrl: string, target?: string): string | null {
|
||||
const params = new URL(pageUrl, 'https://portal.invalid').searchParams;
|
||||
if (!params.get('complement_origin') || !params.get('complement_code') || (target && params.get('complement_target') !== target)) return null;
|
||||
const query = new URLSearchParams();
|
||||
for (const field of ['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'selection_key']) if(params.get(field)) query.set(field,params.get(field)!);
|
||||
const dossier = params.get('investment_dossier');
|
||||
return (dossier && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(dossier) ? `/investimentos/${dossier}/contexto/editar` : '/investimentos/iniciar')+'?'+query;
|
||||
}
|
||||
|
||||
/** Carry the explicitly consulted portfolio origin and search geography into the existing preparation entry. */
|
||||
export function investmentPreparationHref(opportunity: number, pageUrl: string, origin?: string | null): string {
|
||||
const complementary = complementPreparationHref(pageUrl, `opportunity:${opportunity}`);
|
||||
if (complementary) return complementary;
|
||||
const source = new URL(pageUrl, 'https://portal.invalid');
|
||||
const inherited = source.searchParams.has('catalogue_query') ? new URLSearchParams(source.searchParams.get('catalogue_query')!) : source.searchParams;
|
||||
const query = new URLSearchParams({source: `opportunity:${opportunity}`});
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { HomepageSectors } from '@/components/public-sector-cards';
|
||||
import {
|
||||
HOME_SECTION,
|
||||
HOME_SECTION_BAND,
|
||||
HOME_SECTION_HEADING,
|
||||
} from '@/lib/portal-presentation';
|
||||
import InvestmentContextCards from '@/components/investment-context-cards';
|
||||
import InvestmentSteps from '@/components/investment-steps';
|
||||
import PortalHero from '@/components/portal-hero';
|
||||
import SearchState from '@/components/search-state';
|
||||
import { useInterestDraft } from '@/hooks/use-interest-draft';
|
||||
import { createContext, useContext, useRef, useState } from 'react';
|
||||
@@ -7,7 +16,6 @@ import { ProvinceSelect } from '@/components/investment-territory';
|
||||
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
|
||||
import CountrySelect, { type Country } from '@/components/country-select';
|
||||
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { usePortalQuery } from '@/hooks/use-portal-query';
|
||||
import {
|
||||
PortalApiError,
|
||||
@@ -30,30 +38,20 @@ import type {
|
||||
Sector,
|
||||
} from '@/lib/portal-api';
|
||||
import {
|
||||
Search,
|
||||
Leaf,
|
||||
Zap,
|
||||
Hotel,
|
||||
Truck,
|
||||
Waves,
|
||||
Banknote,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
MapPin,
|
||||
CheckCircle2,
|
||||
LogIn,
|
||||
FileText,
|
||||
Scale,
|
||||
Handshake,
|
||||
Calculator,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
type View = 'home' | 'opportunities' | 'detail' | 'interest';
|
||||
type OpportunityAction = (opportunity: Opportunity) => void;
|
||||
const SectorContext = createContext<Sector[]>([]);
|
||||
const SECTOR_ICONS: Record<string, LucideIcon> = {
|
||||
agronegocio: Leaf,
|
||||
energia: Zap,
|
||||
turismo: Hotel,
|
||||
infraestruturas: Truck,
|
||||
'economia-azul': Waves,
|
||||
'mercado-financeiro': Banknote,
|
||||
};
|
||||
|
||||
function QueryStatus({
|
||||
loading,
|
||||
@@ -68,7 +66,7 @@ function QueryStatus({
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="mx-auto max-w-6xl px-5 py-8 text-slate-600"
|
||||
className="mx-auto max-w-6xl px-5 py-5 text-slate-600 sm:py-6"
|
||||
>
|
||||
A carregar dados...
|
||||
</p>
|
||||
@@ -77,7 +75,7 @@ function QueryStatus({
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-auto my-6 max-w-6xl rounded-xl border border-red-200 bg-red-50 p-5 text-red-800"
|
||||
className="mx-auto my-5 max-w-6xl rounded-xl border border-red-200 bg-red-50 p-5 text-red-800"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
@@ -137,172 +135,6 @@ function TopBar({
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({
|
||||
query,
|
||||
setQuery,
|
||||
onSearch,
|
||||
onNavigate,
|
||||
stats,
|
||||
}: {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onSearch: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onNavigate: (view: View) => void;
|
||||
stats?: HomeResponse['data']['stats'];
|
||||
}) {
|
||||
return (
|
||||
<section className="border-b border-slate-200 bg-[#0B2038] text-white">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-16 md:py-24">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl leading-tight font-black tracking-tight text-emerald-300 uppercase md:text-6xl">
|
||||
INVISTA EM MOÇAMBIQUE
|
||||
</p>
|
||||
<h1 className="mx-auto mt-4 max-w-4xl text-4xl leading-[1.05] font-black tracking-tight md:text-5xl">
|
||||
Transforme informação em decisões
|
||||
<span className="text-amber-400">
|
||||
{' '}
|
||||
e ideias em negócios reais.
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-3xl text-lg font-semibold text-slate-200">
|
||||
Oportunidades reais de investimento em Moçambique,
|
||||
organizadas por sector.
|
||||
</p>
|
||||
<p className="mx-auto mt-5 max-w-2xl text-slate-300">
|
||||
Consulte projectos por província e estágio de
|
||||
maturidade, compreenda os requisitos legais e fiscais, e
|
||||
manifeste interesse directamente junto da entidade
|
||||
promotora.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
onClick={() => onNavigate('opportunities')}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-400 px-5 py-3 font-bold text-[#0B2038] hover:bg-amber-300"
|
||||
>
|
||||
Consultar oportunidades <ArrowRight size={16} />
|
||||
</button>
|
||||
<a
|
||||
href="/simulador-fiscal"
|
||||
className="rounded-lg border border-emerald-300 px-5 py-3 font-semibold text-emerald-100 hover:bg-emerald-900"
|
||||
>
|
||||
Planear finanças
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSearch}
|
||||
className="h-fit rounded-2xl border border-white/10 bg-white/10 p-5"
|
||||
>
|
||||
<label
|
||||
htmlFor="portal-opportunity-search"
|
||||
className="mb-2 block text-sm font-semibold text-slate-200"
|
||||
>
|
||||
Pesquisa rápida
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="portal-opportunity-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Ex.: agronegócio, Nampula, energia..."
|
||||
className="w-full rounded-lg bg-white px-3 py-2.5 text-sm text-slate-900 ring-2 ring-transparent outline-none placeholder:text-slate-500 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Pesquisar oportunidades"
|
||||
className="rounded-lg bg-amber-400 px-4 text-[#0B2038]"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3 border-t border-white/10 pt-4 text-center text-xs text-slate-300">
|
||||
<div>
|
||||
<p className="text-xl font-black text-white">
|
||||
{stats?.opportunities ?? '—'}
|
||||
</p>
|
||||
oportunidades
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<p className="text-xl font-black text-white">
|
||||
{stats?.sectors ?? '—'}
|
||||
</p>
|
||||
sectores
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<p className="text-xl font-black text-white">
|
||||
{stats?.provinces ?? '—'}
|
||||
</p>
|
||||
províncias
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectorGrid({
|
||||
onSelectSector,
|
||||
}: {
|
||||
onSelectSector: (slug: string) => void;
|
||||
}) {
|
||||
const sectors = useContext(SectorContext);
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl px-5 py-16">
|
||||
<h2 className="text-2xl font-black text-slate-900">
|
||||
Sectores estratégicos
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">
|
||||
Explore as áreas prioritárias do programa de investimento
|
||||
nacional.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sectors.map((sector) => {
|
||||
const Icon = SECTOR_ICONS[sector.slug] ?? Banknote;
|
||||
const count = sector.opportunitiesCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sector.slug}
|
||||
onClick={() => onSelectSector(sector.slug)}
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700">
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
<h3 className="mt-4 font-bold text-slate-900">
|
||||
{sector.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-600">
|
||||
{sector.shortDescription}
|
||||
</p>
|
||||
{sector.specializations?.map((child) => (
|
||||
<span
|
||||
key={child.id}
|
||||
className="mt-3 block text-sm font-semibold text-emerald-800"
|
||||
>
|
||||
Especialização:{' '}
|
||||
{child.name.replace(
|
||||
'Mercado Financeiro — ',
|
||||
'',
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<p className="mt-4 text-sm font-semibold text-emerald-700 group-hover:underline">
|
||||
{count} oportunidade{count === 1 ? '' : 's'} →
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OpportunityCard({
|
||||
opportunity,
|
||||
onView,
|
||||
@@ -392,7 +224,12 @@ function OpportunitiesView({
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl px-5 py-12">
|
||||
<SearchState loading={query.loading} error={query.error} total={total} retry={query.retry} />
|
||||
<SearchState
|
||||
loading={query.loading}
|
||||
error={query.error}
|
||||
total={total}
|
||||
retry={query.retry}
|
||||
/>
|
||||
<h1 className="text-3xl font-black text-slate-900">
|
||||
Oportunidades de investimento
|
||||
</h1>
|
||||
@@ -705,19 +542,22 @@ function InterestForm({
|
||||
const pending = useRef(false);
|
||||
const submission = useRef<{ body: string; key: string } | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const draft = useInterestDraft<InterestFormData>('opportunity:' + (preselected?.reference ?? 'general'), {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
investorType: '',
|
||||
country: '',
|
||||
declared_origin_country_code: '',
|
||||
sectorInterest: preselected?.sectorName ?? '',
|
||||
opportunityReference: preselected ? preselected.reference : '',
|
||||
investmentRange: '',
|
||||
message: '',
|
||||
privacyConsent: false,
|
||||
});
|
||||
const draft = useInterestDraft<InterestFormData>(
|
||||
'opportunity:' + (preselected?.reference ?? 'general'),
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
investorType: '',
|
||||
country: '',
|
||||
declared_origin_country_code: '',
|
||||
sectorInterest: preselected?.sectorName ?? '',
|
||||
opportunityReference: preselected ? preselected.reference : '',
|
||||
investmentRange: '',
|
||||
message: '',
|
||||
privacyConsent: false,
|
||||
},
|
||||
);
|
||||
const { form, setForm } = draft;
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [result, setResult] = useState<{ reference: string } | null>(null);
|
||||
@@ -779,7 +619,10 @@ function InterestForm({
|
||||
setResult({ reference: response.data.reference });
|
||||
onSubmitted();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof PortalApiError && error.status === 401) { draft.authenticate(); return; }
|
||||
if (error instanceof PortalApiError && error.status === 401) {
|
||||
draft.authenticate();
|
||||
return;
|
||||
}
|
||||
if (error instanceof PortalApiError) {
|
||||
setErrors(fieldErrors(error));
|
||||
setSubmitError(error.message);
|
||||
@@ -805,7 +648,13 @@ function InterestForm({
|
||||
A sua referência é <strong>{result.reference}</strong>. A
|
||||
equipa do portal irá analisar o pedido e poderá solicitar
|
||||
informação adicional.
|
||||
</p><a href="/investidor/manifestacoes" className="mt-4 inline-block underline">Acompanhar manifestações</a>
|
||||
</p>
|
||||
<a
|
||||
href="/investidor/manifestacoes"
|
||||
className="mt-4 inline-block underline"
|
||||
>
|
||||
Acompanhar manifestações
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1053,16 +902,20 @@ function Field({
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-slate-200 bg-slate-950 py-10 text-slate-400">
|
||||
<div className="mx-auto max-w-6xl px-5 text-sm">
|
||||
<p className="font-bold text-white">INVESTE Moçambique</p>
|
||||
<p className="mt-2 max-w-md">
|
||||
Portal do Investidor — conheça as oportunidades de
|
||||
investimento em Moçambique e manifeste o seu interesse.{' '}
|
||||
<a href="/acesso-gestao" className="underline">
|
||||
Acesso à gestão
|
||||
</a>
|
||||
</p>
|
||||
<footer
|
||||
aria-label="Portal do Investidor"
|
||||
className="border-t border-slate-200 bg-white"
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl justify-start px-5 py-2">
|
||||
<a
|
||||
href="/acesso-gestao"
|
||||
aria-label="Acesso à gestão"
|
||||
title="Acesso à gestão"
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium text-[#ed1c24] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
<LogIn className="size-5" aria-hidden="true" />
|
||||
<span>Acesso à gestão</span>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
@@ -1120,11 +973,7 @@ export default function PortalInvestidorApp({
|
||||
|
||||
function handleHeroSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
router.get('/oportunidades', { q: heroQuery });
|
||||
}
|
||||
|
||||
function handleSelectSector(slug: string) {
|
||||
router.get('/sectores/' + encodeURIComponent(slug));
|
||||
router.get('/oportunidades', { q: heroQuery.trim() });
|
||||
}
|
||||
|
||||
function handleView(opportunity: Opportunity) {
|
||||
@@ -1140,121 +989,52 @@ export default function PortalInvestidorApp({
|
||||
return (
|
||||
<SectorContext.Provider value={sectors}>
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||
{view !== 'home' && <TopBar onNavigate={navigate} requestCount={requestCount} />}
|
||||
{view !== 'home' && (
|
||||
<TopBar onNavigate={navigate} requestCount={requestCount} />
|
||||
)}
|
||||
<PortalMenu />
|
||||
<main>
|
||||
{view === 'home' && (
|
||||
<>
|
||||
<Hero
|
||||
<PortalHero
|
||||
query={heroQuery}
|
||||
setQuery={setHeroQuery}
|
||||
onSearch={handleHeroSearch}
|
||||
stats={home.data?.data.stats}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<section className="mx-auto max-w-6xl space-y-5 px-5 py-10">
|
||||
<h2 className="text-2xl font-bold">
|
||||
Encontre o contexto do seu investimento
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
[
|
||||
'/clusters',
|
||||
'Clusters',
|
||||
'Cadeias de valor e lacunas produtivas.',
|
||||
],
|
||||
[
|
||||
'/corredores',
|
||||
'Corredores',
|
||||
'Territórios, logística e conexões.',
|
||||
],
|
||||
[
|
||||
'/projectos-ancora',
|
||||
'Projectos âncora',
|
||||
'Projectos estruturantes e relações.',
|
||||
],
|
||||
[
|
||||
'/educacao',
|
||||
'Educação',
|
||||
'Investimentos em competências e formação.',
|
||||
],
|
||||
[
|
||||
'/documentos',
|
||||
'Documentos e Formulários',
|
||||
'Legislação, procedimentos, formulários e materiais de apoio.',
|
||||
],
|
||||
].map(([href, title, text]) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-xl border bg-white p-5"
|
||||
>
|
||||
<h3 className="text-lg font-bold">
|
||||
{title}
|
||||
</h3>
|
||||
<p>{text}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<InvestmentContextCards />
|
||||
<QueryStatus
|
||||
loading={home.loading}
|
||||
error={home.error}
|
||||
retry={home.retry}
|
||||
/>
|
||||
{home.data && (
|
||||
<SectorGrid
|
||||
onSelectSector={handleSelectSector}
|
||||
<HomepageSectors
|
||||
sectors={sectors.map((sector) => ({
|
||||
...sector,
|
||||
description: sector.shortDescription,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mx-auto max-w-6xl space-y-5 px-5 py-12">
|
||||
<h2 className="text-2xl font-black">
|
||||
Como Investir
|
||||
</h2>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{[
|
||||
[
|
||||
'1. Explorar',
|
||||
'Identifique o sector, território e oportunidade.',
|
||||
'/oportunidades',
|
||||
],
|
||||
[
|
||||
'2. Preparar',
|
||||
'Conheça o percurso e organize os documentos.',
|
||||
'/como-investir',
|
||||
],
|
||||
[
|
||||
'3. Acompanhar',
|
||||
'Envie o pedido e acompanhe o protocolo.',
|
||||
'/balcao-virtual',
|
||||
],
|
||||
].map(([title, text, href]) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-xl border bg-white p-6"
|
||||
>
|
||||
<h3 className="font-bold">
|
||||
{title}
|
||||
</h3>
|
||||
<p>{text}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<InvestmentSteps />
|
||||
|
||||
<section className="border-t border-slate-200 bg-white py-16">
|
||||
<section className={HOME_SECTION_BAND}>
|
||||
<div className="mx-auto max-w-6xl px-5">
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<h2 className="text-2xl font-black text-slate-900">
|
||||
<div className="mb-5 flex items-end justify-between">
|
||||
<h2 className={HOME_SECTION_HEADING}>
|
||||
Oportunidades em destaque
|
||||
</h2>
|
||||
<button
|
||||
onClick={goToOpportunities}
|
||||
className="text-sm font-bold text-emerald-700 hover:underline"
|
||||
className="inline-flex items-center gap-1 text-sm font-bold text-[#ed1c24] hover:underline"
|
||||
>
|
||||
Ver todas →
|
||||
<span>Ver todas</span>
|
||||
<ArrowRight
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{home.data && featured.length === 0 && (
|
||||
@@ -1275,77 +1055,154 @@ export default function PortalInvestidorApp({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mx-auto grid max-w-6xl gap-6 px-5 py-12 md:grid-cols-2">
|
||||
<div className="space-y-4 rounded-xl border bg-white p-6">
|
||||
<h2 className="text-2xl font-black">
|
||||
Documentos essenciais
|
||||
</h2>
|
||||
{essentialResources.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{essentialResources.map(
|
||||
(resource) => (
|
||||
<li key={resource.id}>
|
||||
<a
|
||||
className="underline"
|
||||
href={`/documentos/${resource.id}/download`}
|
||||
>
|
||||
{resource.title}
|
||||
</a>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<p>
|
||||
Consulte legislação, referências e
|
||||
formulários publicados para preparar o
|
||||
seu investimento.
|
||||
</p>
|
||||
<a
|
||||
className="font-semibold text-emerald-800 underline"
|
||||
href="/documentos"
|
||||
>
|
||||
Abrir biblioteca documental
|
||||
</a>
|
||||
<p>
|
||||
<section
|
||||
className={`${HOME_SECTION} grid gap-4 sm:gap-5 md:grid-cols-2`}
|
||||
>
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-start gap-4 p-6">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#ed1c24]/10 text-[#ed1c24]">
|
||||
<FileText
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-slate-900 sm:text-2xl">
|
||||
Documentos essenciais
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 sm:text-base">
|
||||
Consulte legislação, referências
|
||||
e formulários publicados para
|
||||
preparar o seu investimento.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-2 px-6 pb-6">
|
||||
<a
|
||||
className="underline"
|
||||
className="inline-flex items-center justify-between gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-900 transition-colors hover:border-[#ed1c24] hover:bg-red-50/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
href="/documentos"
|
||||
>
|
||||
Abrir biblioteca documental
|
||||
<ArrowRight
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex items-center justify-between gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-900 transition-colors hover:border-[#ed1c24] hover:bg-red-50/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
href="/legislacao"
|
||||
>
|
||||
Legislação e Incentivos
|
||||
<span className="flex items-center gap-2">
|
||||
<Scale
|
||||
className="h-4 w-4 text-[#ed1c24]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Legislação e Incentivos
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-xl bg-emerald-900 p-6 text-white">
|
||||
<h2 className="text-2xl font-black">
|
||||
Apoio ao Investidor
|
||||
</h2>
|
||||
<p>
|
||||
Peça orientação à equipa, proponha uma
|
||||
parceria ou acompanhe o seu pedido.
|
||||
</p>
|
||||
<a
|
||||
className="font-semibold underline"
|
||||
href="/apoio"
|
||||
>
|
||||
Contactar Apoio ao Investidor
|
||||
</a>
|
||||
<p>
|
||||
{essentialResources.length > 0 && (
|
||||
<div className="mt-2 rounded-xl bg-slate-50/80 p-4">
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Destaques
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{essentialResources
|
||||
.slice(0, 3)
|
||||
.map((resource) => (
|
||||
<li
|
||||
key={
|
||||
resource.id
|
||||
}
|
||||
>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-700 underline-offset-4 hover:text-slate-900 hover:underline"
|
||||
href={`/documentos/${resource.id}/download`}
|
||||
>
|
||||
<FileText
|
||||
className="h-4 w-4 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{
|
||||
resource.title
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-start gap-4 p-6">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#ed1c24]/10 text-[#ed1c24]">
|
||||
<Handshake
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-slate-900 sm:text-2xl">
|
||||
Apoio ao Investidor
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 sm:text-base">
|
||||
Peça orientação à equipa,
|
||||
proponha uma parceria ou
|
||||
acompanhe o seu pedido.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-2 px-6 pb-6">
|
||||
<a
|
||||
className="underline"
|
||||
href="/parcerias"
|
||||
className="inline-flex items-center justify-between gap-2 rounded-xl bg-[#ed1c24] px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
href="/apoio"
|
||||
>
|
||||
Bolsa de Parcerias
|
||||
</a>{' '}
|
||||
·{' '}
|
||||
<a
|
||||
className="underline"
|
||||
href="/simulador-fiscal"
|
||||
>
|
||||
Simulador Fiscal
|
||||
Contactar Apoio ao Investidor
|
||||
<ArrowRight
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<a
|
||||
className="inline-flex items-center justify-between gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-900 transition-colors hover:border-[#ed1c24] hover:bg-red-50/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
href="/parcerias"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Handshake
|
||||
className="h-4 w-4 text-[#ed1c24]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Bolsa de Parcerias
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex items-center justify-between gap-2 rounded-xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-900 transition-colors hover:border-[#ed1c24] hover:bg-red-50/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
href="/simulador-fiscal"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Calculator
|
||||
className="h-4 w-4 text-[#ed1c24]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Simulador Fiscal
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import EducationalGuides from '@/components/educational-guides';
|
||||
import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements';
|
||||
import { searchTerritoryHref, investmentPreparationHref } from '@/lib/search-territory';
|
||||
import type { EducationalArticle } from '@/components/educational-article-card';
|
||||
import { ClassificationSummary } from '@/components/opportunity-classification';
|
||||
@@ -13,6 +14,7 @@ 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({
|
||||
complements = [],
|
||||
guides = [],
|
||||
record: r,
|
||||
context: c,
|
||||
@@ -20,6 +22,7 @@ export default function Detail({
|
||||
returnUrl = '/oportunidades',
|
||||
dossierOrigin,
|
||||
}: {
|
||||
complements?: ComplementOption[];
|
||||
guides?: EducationalArticle[];
|
||||
record: Opportunity;
|
||||
context: Context;
|
||||
@@ -57,6 +60,7 @@ export default function Detail({
|
||||
{r.reference} · {r.sector?.name} · {r.province} {r.district}
|
||||
</p>
|
||||
<p className="text-xl">{r.summary}</p>
|
||||
<InvestmentComplements items={complements}/>
|
||||
<ClassificationSummary rows={c.classification ?? []} />
|
||||
<TerritorialSources sources={c.source_evidence} />
|
||||
<section className={card + ' space-y-3'}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ContextDocuments from '@/components/context-documents';
|
||||
import { SelectedComplements, type Complement } from '@/components/investment-complements';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Frame, card, input, button } from '@/pages/investment/shared';
|
||||
@@ -10,23 +11,26 @@ export default function ContextStart({
|
||||
opportunityChoices = [],
|
||||
step = null,
|
||||
entryQuery = {},
|
||||
entryComplements = [],
|
||||
}: {
|
||||
contexts: { id: string; title: string; version: number }[];
|
||||
source: ContextReference | null;
|
||||
opportunityChoices?: OpportunityChoice[];
|
||||
step?: string | null;
|
||||
entryQuery?: Record<string, string>;
|
||||
entryComplements?: Complement[];
|
||||
}) {
|
||||
const [chosen, setChosen] = useState('');
|
||||
const [title, setTitle] = useState(source?.title ?? opportunityChoices[0]?.opportunity.title ?? '');
|
||||
const [title, setTitle] = useState(entryComplements[0]?.kind === 'need' ? `Preparação de ${entryComplements[0].title} — ${entryComplements[0].origin.title}`.slice(0,200) : source?.title ?? opportunityChoices[0]?.opportunity.title ?? '');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [key] = useState(createOperationKey);
|
||||
const sourceSuffix = opportunityChoices.length ? '?' + opportunityChoices.map(c => `opportunities[]=${encodeURIComponent(c.opportunity.id)}`).join('&') : source ? '?source=' + encodeURIComponent(source.key) : '';
|
||||
const suffix = sourceSuffix ? sourceSuffix+'&selection_key='+encodeURIComponent(key)+'&'+new URLSearchParams(entryQuery).toString() : '';
|
||||
const suffix = sourceSuffix || Object.keys(entryQuery).length ? (sourceSuffix || '?')+(sourceSuffix ? '&' : '')+'selection_key='+encodeURIComponent(key)+'&'+new URLSearchParams(entryQuery).toString() : '';
|
||||
return (
|
||||
<Frame title="Iniciar ou continuar um investimento">
|
||||
<SelectedComplements items={entryComplements}/>
|
||||
{!!opportunityChoices.length && <section className={card}><h2 className="font-bold">Oportunidades escolhidas</h2><ul>{opportunityChoices.map(c => <li key={c.opportunity.key}><Link className="underline" href={c.opportunity.href}>{c.opportunity.title}</Link> · {c.territory?.title ?? 'Localização por escolher'}{c.portfolio_origins?.map(origin=><p key={origin.id}>Associação escolhida: <Link className="underline" href={origin.href}>{origin.title}</Link></p>)}</li>)}</ul><p>Confirme a localização de cada ficha na comparação do dossier. Ao continuar um dossier, as escolhas já guardadas conservam-se.</p></section>}
|
||||
<p>
|
||||
Ideia, oportunidade, sector e localização convergem no mesmo
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
PreparationImpact,
|
||||
} from '@/components/private-investment-preparation';
|
||||
import ContextDocuments from '@/components/context-documents';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import InvestmentComplements, { SelectedComplements, complementInput, type Complement, type ComplementOption } from '@/components/investment-complements';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Frame, button, card, input } from '@/pages/investment/shared';
|
||||
import {
|
||||
@@ -39,6 +40,7 @@ export default function ContextEditor({
|
||||
draft = null,
|
||||
entrySources = [],
|
||||
entryLocationDifferences = [],
|
||||
entryComplements = [],
|
||||
}: {
|
||||
initial: ContextView;
|
||||
options: Options;
|
||||
@@ -46,12 +48,34 @@ export default function ContextEditor({
|
||||
territorySelection?: TerritoryOption[];
|
||||
requestedChoices?: OpportunityChoice[] | null;
|
||||
entrySources?: string[];
|
||||
entryComplements?: Complement[];
|
||||
entryLocationDifferences?: {title:string;saved:string;requested:string}[];
|
||||
draft?: {draft_sequence?:number;needs_review?:boolean;selection?:Partial<Record<Slot,string>>;declarations?:Record<string,string>;replace_fields?:string[];document_links?:Record<string,string|null>}|null;
|
||||
}) {
|
||||
const navigation = useNavigationContext();
|
||||
const [complements, setComplements] = useState<Complement[]>(entryComplements);
|
||||
const [complementOptions, setComplementOptions] = useState<ComplementOption[]>([]);
|
||||
const [complementError, setComplementError] = useState('');
|
||||
const [choices, setChoices] = useState<OpportunityChoice[]>(requestedChoices ?? initial.context.opportunity_choices ?? []);
|
||||
const [manageChoices, setManageChoices] = useState(requestedChoices !== null || !!initial.context.opportunity_choices?.length);
|
||||
const complementContexts = JSON.stringify([...choices.flatMap(c => [{origin:c.opportunity.key, territory_id:c.territory?.id ?? null}, ...(c.portfolio_origins ?? []).map(p => ({origin:'portfolio:'+p.id,territory_id:c.territory?.id ?? null}))]), ...complements.filter(c => c.origin.key.startsWith('portfolio:')).map(c => ({origin:c.origin.key,territory_id:c.territory_id}))].filter((c,i,all) => all.findIndex(other => other.origin === c.origin && other.territory_id === c.territory_id) === i));
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setComplementError(''); setComplementOptions([]);
|
||||
const contexts = JSON.parse(complementContexts) as {origin:string;territory_id:string|null}[];
|
||||
const load = async () => {
|
||||
const items: ComplementOption[] = [];
|
||||
const issues: string[] = [];
|
||||
for(let index=0;index<contexts.length;index+=100) {
|
||||
const result = await contextApi<{items:ComplementOption[];issues:{message:string}[]}>('/complementos-investimento', {contexts:contexts.slice(index,index+100)});
|
||||
items.push(...result.items);issues.push(...result.issues.map(issue => issue.message));
|
||||
if(!active) return;
|
||||
}
|
||||
if(active) {setComplementOptions(items);setComplementError([...new Set(issues)].join(' '));}
|
||||
};
|
||||
void load().catch(e => {if(active) setComplementError(e.message);});
|
||||
return () => {active=false;};
|
||||
}, [complementContexts]);
|
||||
const [view, setView] = useState(initial);
|
||||
const [preparation, setPreparation] = useState(initial.private_investment);
|
||||
const [options, setOptions] = useState(initialOptions);
|
||||
@@ -97,7 +121,7 @@ export default function ContextEditor({
|
||||
}).catch(e=>{if(active)setCorridorError(e.message)}).finally(()=>{if(active)setCorridorLoading(false)});
|
||||
return()=>{active=false};
|
||||
},[firstCorridor,firstTerritory,corridorRetry]);
|
||||
const draftBody=JSON.stringify({entry_sources:entrySources,version:view.case.version,selection:changes,declarations,replace_fields:replace,document_links:documentLinks,
|
||||
const draftBody=JSON.stringify({complements:complements.map(complementInput),entry_sources:entrySources,version:view.case.version,selection:changes,declarations,replace_fields:replace,document_links:documentLinks,
|
||||
...(manageChoices?{opportunity_choices:choices.map(c=>({source:c.opportunity.key,portfolio_origins:(c.portfolio_origins??[]).map(r=>r.id),corridor_key:c.corridor_key,territory:c.territory?.key??null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))}:{})});
|
||||
const latestDraft=useRef(draftBody);latestDraft.current=draftBody;
|
||||
const skipNextDraft=useRef(false);
|
||||
@@ -167,6 +191,7 @@ export default function ContextEditor({
|
||||
setError('');
|
||||
setMessage('');
|
||||
const body = {
|
||||
complements: complements.map(complementInput),
|
||||
...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,portfolio_origins:(c.portfolio_origins??[]).map(r=>r.id),corridor_key:c.corridor_key,territory:c.territory?.key ?? null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))} : {}),
|
||||
version: view.case.version,
|
||||
selection: changes,
|
||||
@@ -215,6 +240,7 @@ export default function ContextEditor({
|
||||
window.history.replaceState(window.history.state, '', `/investimentos/${view.case.id}/contexto/editar${window.location.hash}`);
|
||||
setView(next);
|
||||
setChoices(next.context.opportunity_choices ?? []);
|
||||
setComplements(next.context.complements ?? []);
|
||||
setPreparation(next.private_investment);
|
||||
navigation.refresh();
|
||||
setChanges({});
|
||||
@@ -249,6 +275,18 @@ export default function ContextEditor({
|
||||
{view.case.title}
|
||||
</p>
|
||||
<p>Confirme as oportunidades e a localização, complete os dados disponíveis e reveja antes de guardar.</p>
|
||||
<SelectedComplements items={complements} onRemove={busy ? undefined : key => {setComplements(items => items.filter(item => item.key !== key));setPlan(null);}}/>
|
||||
{complementError && <p role="alert">{complementError} As escolhas anteriores foram conservadas.</p>}
|
||||
<InvestmentComplements items={complementOptions} dossierId={view.case.id} onNavigate={async href => {
|
||||
if(busy) return;
|
||||
setBusy(true);setError('');
|
||||
try {
|
||||
await queue.current.catch(()=>{});
|
||||
await contextApi('/investimentos/'+view.case.id+'/contexto/rascunho', {...JSON.parse(latestDraft.current), draft_sequence:++sequence.current.value}, 'POST');
|
||||
router.visit(href);
|
||||
} catch(e) {setError(e instanceof Error ? e.message : 'Não foi possível conservar as escolhas antes de abrir o complemento.');}
|
||||
finally {setBusy(false);}
|
||||
}}/>
|
||||
{!!entryLocationDifferences.length && <aside className="rounded border border-amber-500 p-3" aria-label="Localização anterior conservada">{entryLocationDifferences.map(row=><p key={row.title}>{row.title}: a localização recebida ({row.requested}) não substituiu a escolha guardada ({row.saved}). Pode rever a localização abaixo antes de confirmar.</p>)}</aside>}
|
||||
{error && (
|
||||
<div
|
||||
@@ -642,6 +680,7 @@ export default function ContextEditor({
|
||||
Comparação antes da confirmação
|
||||
</h2>
|
||||
{!!plan.opportunity_choices?.length && <OpportunityChoices choices={plan.opportunity_choices}/>}
|
||||
<SelectedComplements items={plan.complements ?? []}/>
|
||||
<InvestmentRequirements assessment={plan.requirements_assessment} contextId={view.case.id}/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] table-fixed text-left">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type Preparation,
|
||||
} from '@/components/private-investment-preparation';
|
||||
import ContextDocuments from '@/components/context-documents';
|
||||
import { SelectedComplements } from '@/components/investment-complements';
|
||||
import InvestmentRequirements from '@/components/investment-requirements';
|
||||
import OpportunityChoices from '@/components/investment-opportunity-choices';
|
||||
import type { DossierContext } from '@/lib/investor-context';
|
||||
@@ -531,6 +532,7 @@ export default function InvestmentPath(path: Path) {
|
||||
/>
|
||||
)}
|
||||
{!!path.investment_context?.opportunity_choices?.length && <OpportunityChoices choices={path.investment_context.opportunity_choices}/>}
|
||||
<SelectedComplements items={path.investment_context?.complements ?? []}/>
|
||||
<InvestmentRequirements assessment={path.investment_context?.requirements_assessment} contextId={path.case.id} excludedSteps={path.steps.filter((step) => !step.preparation_allowed).map((step) => step.code)}/>
|
||||
{!!path.content_sources?.length && (
|
||||
<section
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { ComplementEntryActions } from '@/components/investment-complements';
|
||||
import StructureGeographyPanel, {
|
||||
type StructureGeography,
|
||||
} from '@/components/structure-geography';
|
||||
@@ -478,6 +479,7 @@ export default function Detail(p: Props) {
|
||||
const { auth } = usePage<{ auth: { user: unknown } }>().props;
|
||||
return (
|
||||
<Frame title={p.record.title || 'Pré-visualização da ideia'}>
|
||||
<ComplementEntryActions target={'partnership:'+p.record.id}/>
|
||||
<div className="space-y-6">
|
||||
<p>
|
||||
{p.record.partnership_type
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { InvestmentContextGrid } from '@/components/investment-context-cards';
|
||||
import { PORTAL_MORE_LINK } from '@/lib/portal-presentation';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
export default function InvestmentContexts() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<Head title="Contextos de investimento" />
|
||||
<main className="mx-auto max-w-6xl min-w-0 space-y-6 px-5 py-10">
|
||||
<h1 className="text-3xl font-bold text-[#ed1c24]">
|
||||
Encontre o contexto do seu investimento
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
Explore os contextos e recursos de apoio ao seu
|
||||
investimento.
|
||||
</p>
|
||||
<InvestmentContextGrid />
|
||||
<div className="flex justify-center">
|
||||
<a href="/" className={PORTAL_MORE_LINK}>
|
||||
Voltar ao início
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PublicSectorCards } from '@/components/public-sector-cards';
|
||||
import { PORTAL_MORE_LINK } from '@/lib/portal-presentation';
|
||||
import {
|
||||
PublicDocumentContent,
|
||||
type PublicDocument,
|
||||
@@ -18,7 +20,7 @@ const input =
|
||||
const button =
|
||||
'inline-block rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white disabled:opacity-50';
|
||||
const titles: Record<string, string> = {
|
||||
sectors: 'Sectores Prioritários',
|
||||
sectors: 'Sectores de investimento',
|
||||
sector: 'Sector prioritário',
|
||||
procedures: 'Como Investir em Moçambique',
|
||||
legislation: 'Legislação e Incentivos',
|
||||
@@ -73,8 +75,9 @@ type Props = {
|
||||
sectors?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
short_description: string;
|
||||
short_description: string | null;
|
||||
opportunities_count: number;
|
||||
url?: string;
|
||||
specializations?: { id: number; name: string; url: string }[];
|
||||
}[];
|
||||
sector?: {
|
||||
@@ -489,7 +492,15 @@ export default function Services(p: Props) {
|
||||
<Head title={title} />
|
||||
<PortalMenu />
|
||||
<main className="mx-auto max-w-6xl min-w-0 space-y-8 overflow-x-clip px-5 py-10">
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
<h1
|
||||
className={
|
||||
p.section === 'sectors'
|
||||
? 'text-3xl font-bold text-[#ed1c24]'
|
||||
: 'text-3xl font-bold'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{flash?.success && (
|
||||
<p
|
||||
role="status"
|
||||
@@ -499,38 +510,33 @@ export default function Services(p: Props) {
|
||||
</p>
|
||||
)}
|
||||
{p.section === 'sectors' && (
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{p.sectors?.map((s) => (
|
||||
<article
|
||||
className="rounded-xl border bg-white p-6"
|
||||
key={s.slug}
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
<Link href={`/sectores/${s.slug}`}>
|
||||
{s.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="my-3">{s.short_description}</p>
|
||||
{s.specializations?.map((child) => (
|
||||
<p key={child.id}>
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href={child.url}
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
</p>
|
||||
))}
|
||||
<p>
|
||||
{s.opportunities_count} oportunidades
|
||||
publicadas
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<section
|
||||
aria-label="Todos os sectores de investimento"
|
||||
className="space-y-6"
|
||||
>
|
||||
<p className="text-slate-600">
|
||||
Explore o potencial estratégico de cada sector em
|
||||
Moçambique.
|
||||
</p>
|
||||
<PublicSectorCards
|
||||
sectors={(p.sectors ?? []).map((sector) => ({
|
||||
slug: sector.slug,
|
||||
name: sector.name,
|
||||
description: sector.short_description,
|
||||
opportunitiesCount: sector.opportunities_count,
|
||||
specializations: sector.specializations,
|
||||
url: sector.url,
|
||||
}))}
|
||||
/>
|
||||
{!p.sectors?.length && (
|
||||
<p>Não existem sectores publicados.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<a href="/" className={PORTAL_MORE_LINK}>
|
||||
Voltar ao início
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{p.section === 'sector' && p.sector && (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import PartnershipSourceLinks from '@/components/partnership-source-links';
|
||||
import { Management } from '../management/shared';
|
||||
import { Layout, Field, control, button, labels, kind } from './shared';
|
||||
import type { Dossier } from './shared';
|
||||
import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements';
|
||||
export default function Show(props: Props) {
|
||||
return (
|
||||
<Editor
|
||||
@@ -20,6 +21,7 @@ export default function Show(props: Props) {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
complements?: ComplementOption[];
|
||||
geography: StructureGeography;
|
||||
existingSubmissions?: { id: string; version: number; updated_at: string }[];
|
||||
anchorProject?: {
|
||||
@@ -52,6 +54,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Editor({
|
||||
complements = [],
|
||||
record,
|
||||
related,
|
||||
management,
|
||||
@@ -200,6 +203,7 @@ function Editor({
|
||||
</section>
|
||||
)}
|
||||
{origin && <p>Exploração iniciada em: {origin.title}</p>}
|
||||
{!management && <InvestmentComplements items={complements}/>}
|
||||
{!management && auth.user && (
|
||||
<div className="space-y-2 rounded-xl border p-4">
|
||||
<p>
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Http\Middleware\PrivateInvestmentResponse;
|
||||
use App\Http\Middleware\VirtualDeskInstalled;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::match(['GET', 'POST'], '/complementos-investimento', [\App\Http\Controllers\InvestmentComplementController::class, 'index'])->middleware('throttle:60,1');
|
||||
|
||||
Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, VirtualDeskInstalled::class, 'throttle:60,1'])->prefix('investimentos')->group(function () {
|
||||
Route::get('/', [Path::class, 'index']);
|
||||
Route::get('/iniciar', [InvestorContextController::class, 'start']);
|
||||
|
||||
@@ -49,6 +49,7 @@ use Laravel\Fortify\Features;
|
||||
|
||||
// Route::inertia('/', 'welcome')->name('home');
|
||||
Route::get('/', [PortalServicesController::class, 'home'])->name('home');
|
||||
Route::inertia('/contextos-de-investimento', 'portal/investment-contexts')->name('portal.investment-contexts');
|
||||
Route::inertia('/politica-de-privacidade', 'portal/privacy-policy')->name('legal.privacy');
|
||||
Route::inertia('/termos-de-uso', 'portal/terms-of-use')->name('legal.terms');
|
||||
foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao' => 'legislation', 'documentos' => 'documents',
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentComplements;
|
||||
use App\Services\PartnershipExchange;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class InvestmentComplementsTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
|
||||
$this->assertSame(55441, (int) $target->port);
|
||||
$this->withoutVite();
|
||||
config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
}
|
||||
|
||||
private function source(): string
|
||||
{
|
||||
return 'portfolio:'.InvestmentPortfolioController::visible()->where('content->code', 'COR-02')->value('id');
|
||||
}
|
||||
|
||||
public function test_public_complements_link_existing_identities_and_distinguish_unoffered_needs(): void
|
||||
{
|
||||
$source = $this->source();
|
||||
$options = $this->getJson('/complementos-investimento?'.http_build_query(['origin' => $source]))->assertOk()->json();
|
||||
$storage = collect($options)->firstWhere('code', 'storage');
|
||||
$this->assertNotEmpty($storage['opportunities']);
|
||||
$offer = $storage['opportunities'][0];
|
||||
$record = Opportunity::findOrFail(substr($offer['key'], 12));
|
||||
$this->assertSame('/oportunidades/'.$record->slug, $offer['href']);
|
||||
$this->get($offer['href'])->assertOk();
|
||||
$spinning = collect($options)->firstWhere('code', 'spinning');
|
||||
$this->assertSame([], $spinning['opportunities']);
|
||||
$query = http_build_query(['complement_origin' => $source, 'complement_code' => 'spinning']);
|
||||
$this->get('/investimentos/iniciar?'.$query)->assertRedirect(route('login'));
|
||||
$this->assertStringContainsString('complement_code=spinning', session('url.intended'));
|
||||
$this->getJson('/complementos-investimento?origin=sector:1')->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_complements_survive_draft_confirmation_and_consumer_snapshot_without_overwriting_title(): void
|
||||
{
|
||||
$actor = User::factory()->create(); $this->actingAs($actor);
|
||||
$origin = $this->source();
|
||||
$option = collect(app(InvestmentComplements::class)->options($origin))->firstWhere('code', 'storage');
|
||||
$target = $option['opportunities'][0]['key'];
|
||||
$need = ['origin' => $origin, 'code' => 'spinning', 'target' => null];
|
||||
$offer = ['origin' => $origin, 'code' => 'storage', 'target' => $target];
|
||||
$query = http_build_query(['complement_origin' => $origin, 'complement_code' => 'storage', 'complement_target' => $target]);
|
||||
$this->get('/investimentos/iniciar?'.$query)->assertOk()->assertInertia(fn(Assert $p) => $p->where('entryComplements.0.target.key', $target)->where('opportunityChoices.0.opportunity.key', $target));
|
||||
$id = $this->postJson('/investimentos', ['title' => 'QA título deliberado dos complementos', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
|
||||
$body = ['version' => $version, 'selection' => [], 'complements' => [$need, $offer], 'opportunity_choices' => [['source' => $target, 'portfolio_origins' => [substr($origin, 10)]]]];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 1])->assertOk();
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar')->assertOk()->assertInertia(fn(Assert $p) => $p->has('entryComplements', 2)->where('entryComplements.0.kind', 'need')->where('entryComplements.1.target.key', $target));
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
$this->assertSame('QA título deliberado dos complementos', $plan['fields']['project_title']);
|
||||
$control = [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
|
||||
$this->flushSession(); $this->actingAs($actor);
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$this->assertSame($plan['complements'], $view['context']['complements']);
|
||||
$this->assertSame($plan['complements'], json_decode($view['prefill']['simulator']['context_applicability_snapshot'], true)['complements']);
|
||||
$this->get('/investimentos/'.$id)->assertOk()->assertInertia(fn(Assert $p) => $p->has('investment_context.complements', 2));
|
||||
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_offer_cannot_be_forged_or_confirmed_after_withdrawal(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$id = $this->postJson('/investimentos', ['title' => 'QA complemento', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$origin = $this->source();
|
||||
$target = collect(app(InvestmentComplements::class)->options($origin))->firstWhere('code', 'storage')['opportunities'][0]['key'];
|
||||
$base = ['version' => $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'), 'selection' => []];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/previa', [...$base, 'complements' => [['origin' => $origin, 'code' => 'spinning', 'target' => $target]]])->assertUnprocessable();
|
||||
$body = [...$base, 'opportunity_choices' => [['source' => $target]], 'complements' => [['origin' => $origin, 'code' => 'storage', 'target' => $target]]];
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
Opportunity::whereKey(substr($target, 12))->update(['status' => 'draft']);
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertUnprocessable();
|
||||
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.revision', 0);
|
||||
}
|
||||
|
||||
public function test_concrete_training_and_finance_services_are_reused_only_for_their_documented_origin(): void
|
||||
{
|
||||
$actor = User::factory()->create(); $this->actingAs($actor);
|
||||
$origin = $this->source();
|
||||
foreach (['training' => 'Formação e certificação', 'finance' => 'Financiamento'] as $type => $title) {
|
||||
$service = app(PartnershipExchange::class)->save($actor, ['client_key' => (string) Str::uuid(), 'title' => $title, 'kind' => 'offering', 'partnership_type' => $type,
|
||||
'payload' => ['summary' => 'QA oferta '.$title, 'consent' => true], 'origins' => [['type' => 'dossier', 'target_id' => substr($origin, 10)]]]);
|
||||
// Published fixture only in this rolled-back isolated QA transaction.
|
||||
DB::table('portal_partnerships')->where('id', $service->id)->update(['status' => 'published']);
|
||||
$code = $type === 'finance' ? 'finance' : 'training';
|
||||
$option = collect(app(InvestmentComplements::class)->options($origin))->firstWhere('code', $code);
|
||||
$this->assertSame('partnership:'.$service->id, $option['services'][0]['key']);
|
||||
$this->get($option['services'][0]['href'])->assertOk();
|
||||
$resolved = app(InvestmentComplements::class)->resolve([['origin' => $origin, 'code' => $code, 'target' => 'partnership:'.$service->id]]);
|
||||
$this->assertSame('service', $resolved[0]['kind']);
|
||||
}
|
||||
$other = InvestmentPortfolioController::visible()->where('content->code', 'COR-01')->firstOrFail();
|
||||
$this->assertSame([], collect(app(InvestmentComplements::class)->options('portfolio:'.$other->id))->flatMap(fn($r) => $r['services'])->all());
|
||||
}
|
||||
|
||||
public function test_explicit_complementary_project_relationship_and_geography_control_the_available_fiche(): void
|
||||
{
|
||||
$records = Opportunity::published()->orderBy('id')->take(2)->get();
|
||||
[$main, $target] = [$records[0], $records[1]];
|
||||
$main->summary = 'QA investimento principal'; $main->save();
|
||||
$target->title = 'Regadio QA relacionado'; $target->summary = 'Regadio para a cadeia de valor QA'; $target->save();
|
||||
$projects = InvestmentPortfolioController::visible()->whereNotNull('project_id')->take(2)->pluck('project_id');
|
||||
DB::table('opportunity_projects')->whereIn('opportunity_id', [$main->id, $target->id])->delete();
|
||||
DB::table('opportunity_projects')->insert([['opportunity_id' => $main->id, 'project_id' => $projects[0]], ['opportunity_id' => $target->id, 'project_id' => $projects[1]]]);
|
||||
$link = (string) Str::uuid();
|
||||
DB::table('complementary_project_links')->insert(['id' => $link, 'anchor_project_id' => $projects[0], 'complementary_project_id' => $projects[1], 'relation_type' => 'enabler', 'data_classification' => 'public']);
|
||||
$service = app(InvestmentComplements::class);
|
||||
$options = $service->options('opportunity:'.$main->id);
|
||||
$this->assertSame('opportunity:'.$target->id, collect($options)->firstWhere('code', 'irrigation')['opportunities'][0]['key']);
|
||||
$sofala = collect(app(\App\Services\InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code', '07')->id;
|
||||
$gaza = app(\App\Services\InvestmentCorridorContext::class)->provinces('limpopo')[0]->id;
|
||||
$target->primary_territory_id = $gaza; $target->save();
|
||||
$filtered = $service->options('opportunity:'.$main->id, $sofala);
|
||||
$this->assertSame([], collect($filtered)->firstWhere('code', 'irrigation')['opportunities']);
|
||||
$batch = $this->postJson('/complementos-investimento', ['contexts' => [['origin' => 'opportunity:'.$main->id, 'territory_id' => $sofala]]])->assertOk()->json();
|
||||
$this->assertSame([], collect($batch['items'])->firstWhere('code', 'irrigation')['opportunities']);
|
||||
DB::table('complementary_project_links')->where('id', $link)->update(['data_classification' => 'internal']);
|
||||
$this->assertSame([], $service->options('opportunity:'.$main->id));
|
||||
}
|
||||
|
||||
public function test_previous_relation_is_preserved_as_history_when_an_offer_is_withdrawn(): void
|
||||
{
|
||||
$service = app(InvestmentComplements::class); $origin = $this->source();
|
||||
$target = collect($service->options($origin))->firstWhere('code', 'storage')['opportunities'][0]['key'];
|
||||
$input = [['origin' => $origin, 'code' => 'storage', 'target' => $target]];
|
||||
$previous = $service->resolve($input);
|
||||
Opportunity::whereKey(substr($target, 12))->update(['status' => 'draft']);
|
||||
$history = $service->resolve($input, $previous);
|
||||
$this->assertSame($previous[0]['target'], $history[0]['target']);
|
||||
$this->assertSame('review_required', $history[0]['availability']);
|
||||
DB::table('investment_dossiers')->where('id', substr($origin, 10))->update(['publication_status' => 'draft']);
|
||||
$this->assertSame($history, $service->resolve($input, $previous));
|
||||
$this->assertSame(['items' => [], 'issues' => []], $this->postJson('/complementos-investimento', ['contexts' => []])->assertOk()->json());
|
||||
$this->postJson('/complementos-investimento', ['contexts' => [['origin' => $origin], ['origin' => 'portfolio:'.InvestmentPortfolioController::visible()->where('content->code', 'COR-01')->value('id')]]])
|
||||
->assertOk()->assertJsonCount(1, 'issues')->assertJsonPath('issues.0.origin', $origin);
|
||||
}
|
||||
|
||||
public function test_removing_then_explicitly_selecting_a_need_keeps_the_draft_and_does_not_reapply_an_old_entry(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$id = $this->postJson('/investimentos', ['title' => 'QA retirada de complemento', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$origin = $this->source(); $selection = (string) Str::uuid();
|
||||
$entry = app(InvestmentComplements::class)->resolve([['origin' => $origin, 'code' => 'spinning']])[0];
|
||||
$query = http_build_query(['complement_origin' => $origin, 'complement_code' => 'spinning', 'selection_key' => $selection]);
|
||||
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.$selection);
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar?'.$query)->assertOk()->assertInertia(fn(Assert $p) => $p->where('entrySources.0', $tag)->has('entryComplements', 1));
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho', ['version' => $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),
|
||||
'draft_sequence' => 1, 'selection' => [], 'complements' => [], 'entry_sources' => [$tag], 'declarations' => ['project_title' => 'QA edição a conservar']])->assertOk();
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar?'.$query)->assertOk()->assertInertia(fn(Assert $p) => $p->has('entryComplements', 0)->where('draft.declarations.project_title', 'QA edição a conservar'));
|
||||
$query = http_build_query(['complement_origin' => $origin, 'complement_code' => 'spinning', 'selection_key' => (string) Str::uuid()]);
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar?'.$query)->assertOk()->assertInertia(fn(Assert $p) => $p->has('entryComplements', 1)->where('draft.declarations.project_title', 'QA edição a conservar'));
|
||||
}
|
||||
|
||||
public function test_public_chain_contract_preserves_both_documentary_references_in_the_selected_relation(): void
|
||||
{
|
||||
$records = Opportunity::published()->orderBy('id')->take(2)->get();
|
||||
[$main, $target] = [$records[0], $records[1]];
|
||||
$main->summary = 'QA produção e processamento'; $main->save();
|
||||
$target->summary = 'QA processamento da mesma cadeia'; $target->save();
|
||||
$row = ['target_type' => 'chain', 'target_id' => (string) Str::uuid(), 'label' => 'Cadeia sintética QA', 'state' => 'preliminary', 'source_reference' => 'Referência documental sintética QA'];
|
||||
$classification = \Mockery::mock(\App\Services\OpportunityClassification::class);
|
||||
$classification->shouldReceive('publicRows')->andReturnUsing(fn($record) => in_array($record->id, [$main->id, $target->id], true) ? [$row] : []);
|
||||
$this->app->instance(\App\Services\OpportunityClassification::class, $classification);
|
||||
$service = app(InvestmentComplements::class);
|
||||
$options = $service->options('opportunity:'.$main->id);
|
||||
$offer = collect(collect($options)->firstWhere('code', 'processing')['opportunities'])->firstWhere('key', 'opportunity:'.$target->id);
|
||||
$this->assertNotNull($offer);
|
||||
$this->assertSame('value_chain', $offer['relationship']['type']);
|
||||
$selected = $service->resolve([['origin' => 'opportunity:'.$main->id, 'code' => 'processing', 'target' => 'opportunity:'.$target->id]]);
|
||||
$this->assertSame($row, $selected[0]['target']['relationship']['principal'][0]);
|
||||
$this->assertSame($row, $selected[0]['target']['relationship']['target'][0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { investmentPreparationHref, complementPreparationHref } from '../../resources/js/lib/search-territory.ts';
|
||||
import { buildProjectDossierRows } from '../../resources/js/lib/project-dossier.ts';
|
||||
|
||||
test('consulting a complementary opportunity preserves the origin selection and the chosen dossier', () => {
|
||||
const dossier='11111111-1111-4111-8111-111111111111';
|
||||
const query = new URLSearchParams({complement_origin:'opportunity:10',complement_code:'cold',complement_target:'opportunity:20',investment_dossier:dossier,complement_territory:dossier});
|
||||
const href=investmentPreparationHref(20,'/oportunidades/frio?'+query);
|
||||
assert.ok(href.startsWith(`/investimentos/${dossier}/contexto/editar?`));
|
||||
const params=new URL(href,'https://local.invalid').searchParams;
|
||||
assert.equal(params.get('complement_origin'),'opportunity:10');
|
||||
assert.equal(params.get('complement_target'),'opportunity:20');
|
||||
assert.equal(params.get('complement_territory'),dossier);
|
||||
assert.equal(complementPreparationHref('/parcerias/7?complement_origin=portfolio:a&complement_code=finance&complement_target=partnership:7','partnership:7'),'/investimentos/iniciar?complement_origin=portfolio%3Aa&complement_code=finance&complement_target=partnership%3A7');
|
||||
assert.equal(complementPreparationHref('/oportunidades/frio?'+query,'opportunity:21'),null);
|
||||
query.set('investment_dossier','https://external.invalid');
|
||||
assert.ok(investmentPreparationHref(20,'/oportunidades/frio?'+query).startsWith('/investimentos/iniciar?'));
|
||||
});
|
||||
|
||||
test('complement report rows retain principal, concrete target and the distinction from a need', () => {
|
||||
const complements=[
|
||||
{title:'Silos',kind:'opportunity',origin:{title:'Cereais'},target:{title:'Armazenagem existente',href:'/oportunidades/silos'}},
|
||||
{title:'Fiação',kind:'need',origin:{title:'Corredor de origem'},target:null},
|
||||
{title:'Formação',kind:'service',origin:{title:'Cereais'},target:{title:'Serviço existente',href:'/parcerias/7'}},
|
||||
];
|
||||
const draft={project_title:'Título deliberado',context_applicability_snapshot:JSON.stringify({version:1,complements})};
|
||||
const before=JSON.stringify(draft);
|
||||
const rows=buildProjectDossierRows(draft);
|
||||
const chosen=rows.filter(row=>row[1]==='Complemento escolhido');
|
||||
assert.equal(chosen.length,3);
|
||||
assert.ok(chosen[0][3].includes('Cereais'));
|
||||
assert.ok(chosen[1][3].includes('necessidade complementar em preparação'));
|
||||
assert.ok(chosen[2][3].includes('serviço/instrumento'));
|
||||
assert.ok(rows.some(row=>row[1]==='Ficha complementar'&&row[3]==='/oportunidades/silos'));
|
||||
assert.equal(JSON.stringify(draft),before);
|
||||
});
|
||||
@@ -14,13 +14,14 @@ const simulator = readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
void test('portal home hero exposes a direct public simulator link', () => {
|
||||
const hero = homePage.slice(
|
||||
homePage.indexOf('function Hero('),
|
||||
homePage.indexOf('function SectorGrid('),
|
||||
void test('portal home investor support exposes a direct public simulator link', () => {
|
||||
const supportHeading = homePage.indexOf('Apoio ao Investidor');
|
||||
assert.ok(supportHeading >= 0, 'home investor support must exist');
|
||||
const support = homePage.slice(
|
||||
supportHeading,
|
||||
homePage.indexOf('</article>', supportHeading),
|
||||
);
|
||||
assert.match(hero, /href="\/simulador-fiscal"/);
|
||||
assert.match(hero, /Planear finanças/);
|
||||
assert.match(support, /<a\s[^>]*href="\/simulador-fiscal"[^>]*>[\s\S]*?Simulador Fiscal[\s\S]*?<\/a>/);
|
||||
});
|
||||
|
||||
void test('completed simulator result keeps public interest submission independent', () => {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// Actual public portal and navigation, synthetic API, local images, no database.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(
|
||||
process.env.CONTEXT_QA_PLAYWRIGHT ||
|
||||
'C:/Users/jorge/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright',
|
||||
);
|
||||
const root = process.cwd();
|
||||
const base = (
|
||||
process.env.CONTEXT_QA_VITE || fs.readFileSync('public/hot', 'utf8')
|
||||
).trim();
|
||||
assert.equal(new URL(base).hostname, '127.0.0.1');
|
||||
const dir = path.join(root, 'outputs/investment-context-compact-20261023');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const fixturePath = '/outputs/investment-context-compact-20261023/fixture.html';
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.html'),
|
||||
`<!doctype html><html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Cartões QA</title></head><body><div id="app"></div><script type="module" src="/outputs/investment-context-compact-20261023/fixture.tsx"></script></body></html>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.tsx'),
|
||||
`
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {createInertiaApp} from '@inertiajs/react';
|
||||
import Portal from '../../resources/js/pages/PortalInvestidor';
|
||||
import Navigation from '../../resources/js/layouts/portal-navigation';
|
||||
import '../../resources/css/app.css';
|
||||
await createInertiaApp({page:{component:'PortalInvestidor',props:{auth:{user:null,canManagePortal:false},essentialResources:[],countries:[]},url:'/',version:null},resolve:()=>Portal,layout:()=>Navigation,setup({el,App,props}){createRoot(el).render(<App {...props}/>);}});
|
||||
`,
|
||||
);
|
||||
const destinations = [
|
||||
['/clusters', 'Clusters', 'Cadeias de valor e lacunas produtivas.'],
|
||||
['/corredores', 'Corredores', 'Territórios, logística e conexões.'],
|
||||
[
|
||||
'/projectos-ancora',
|
||||
'Projectos âncora',
|
||||
'Projectos estruturantes e relações.',
|
||||
],
|
||||
['/educacao', 'Educação', 'Investimentos em competências e formação.'],
|
||||
[
|
||||
'/documentos',
|
||||
'Documentos e Formulários',
|
||||
'Legislação, procedimentos, formulários e materiais de apoio.',
|
||||
],
|
||||
];
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.CONTEXT_QA_CHROMIUM
|
||||
? { executablePath: process.env.CONTEXT_QA_CHROMIUM }
|
||||
: {}),
|
||||
});
|
||||
const errors = [],
|
||||
results = [],
|
||||
navigation = [];
|
||||
let currentPage;
|
||||
async function createCase(width, dpr = 1, fail = 'none') {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 1000 },
|
||||
deviceScaleFactor: dpr,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
// Avoid development HMR reloading the fixture's synthetic homepage URL.
|
||||
await context.routeWebSocket('**/*', () => {});
|
||||
let releaseImages;
|
||||
const imagesReady = new Promise((resolve) => {
|
||||
releaseImages = resolve;
|
||||
});
|
||||
await context.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== base) return route.abort();
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
if (url.pathname.startsWith('/images/investment-context/')) {
|
||||
await imagesReady;
|
||||
if (
|
||||
fail === 'all' ||
|
||||
(fail === 'primary' && !url.pathname.includes('-fallback'))
|
||||
)
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
const file = path.resolve(root, 'public', '.' + url.pathname);
|
||||
assert.ok(
|
||||
file.startsWith(path.join(root, 'public', 'images') + path.sep),
|
||||
);
|
||||
return fs.existsSync(file)
|
||||
? route.fulfill({ path: file })
|
||||
: route.fulfill({ status: 404 });
|
||||
}
|
||||
if (url.pathname === '/api/v1/home')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
success: true,
|
||||
data: {
|
||||
sectors: [],
|
||||
featuredOpportunities: [],
|
||||
stats: { opportunities: 0, sectors: 0, provinces: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (url.pathname.startsWith('/api/'))
|
||||
return route.fulfill({ json: { data: [] } });
|
||||
if (
|
||||
route.request().isNavigationRequest() &&
|
||||
destinations.some(([href]) => url.pathname === href)
|
||||
) {
|
||||
navigation.push(url.pathname);
|
||||
return route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body><h1>Destino de navegação de teste</h1></body></html>',
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
currentPage = page;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base + fixturePath, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000,
|
||||
});
|
||||
const section = page.getByRole('region', {
|
||||
name: 'Encontre o contexto do seu investimento',
|
||||
});
|
||||
await section.waitFor();
|
||||
return { context, page, section, releaseImages };
|
||||
}
|
||||
try {
|
||||
for (const [width, dpr] of [
|
||||
[320, 1],
|
||||
[390, 2],
|
||||
[768, 1],
|
||||
[1024, 1],
|
||||
[1440, 2],
|
||||
[1920, 1],
|
||||
]) {
|
||||
const { context, page, section, releaseImages } = await createCase(
|
||||
width,
|
||||
dpr,
|
||||
);
|
||||
const links = section.getByRole('link');
|
||||
assert.equal(await links.count(), 5);
|
||||
assert.equal(await section.locator('img').count(), 5);
|
||||
const before = await links.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getBoundingClientRect().height),
|
||||
);
|
||||
releaseImages();
|
||||
for (const img of await section.locator('img').all()) {
|
||||
await img.scrollIntoViewIfNeeded();
|
||||
await img.evaluate((image) => image.decode());
|
||||
}
|
||||
const photos = await section.locator('img').evaluateAll((nodes) =>
|
||||
nodes.map((image) => ({
|
||||
src: image.currentSrc,
|
||||
alt: image.alt,
|
||||
lazy: image.loading,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
fit: getComputedStyle(image).objectFit,
|
||||
ratio:
|
||||
image.getBoundingClientRect().width /
|
||||
image.getBoundingClientRect().height,
|
||||
})),
|
||||
);
|
||||
assert.equal(new Set(photos.map((image) => image.src)).size, 5);
|
||||
photos.forEach((image) => {
|
||||
assert.ok(image.alt.length > 20);
|
||||
assert.equal(image.lazy, 'lazy');
|
||||
// naturalWidth is density corrected when srcset uses width descriptors.
|
||||
assert.ok(image.width > 0 && image.height > 0);
|
||||
assert.equal(image.fit, 'cover');
|
||||
assert.ok(Math.abs(image.ratio - 16 / 9) < 0.01);
|
||||
});
|
||||
const after = await links.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getBoundingClientRect().height),
|
||||
);
|
||||
before.forEach((height, index) =>
|
||||
assert.ok(
|
||||
Math.abs(height - after[index]) < 1,
|
||||
'Image caused card layout shift',
|
||||
),
|
||||
);
|
||||
const rows = await links.evaluateAll((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { top: rect.top, height: rect.height };
|
||||
}),
|
||||
);
|
||||
for (const row of rows) {
|
||||
const aligned = rows.filter(
|
||||
(item) => Math.abs(item.top - row.top) < 1,
|
||||
);
|
||||
assert.ok(
|
||||
aligned.every((item) => Math.abs(item.height - row.height) < 1),
|
||||
'Unequal heights within a card row',
|
||||
);
|
||||
}
|
||||
const columns = await section
|
||||
.locator('nav')
|
||||
.evaluate(
|
||||
(node) =>
|
||||
getComputedStyle(node).gridTemplateColumns.split(' ')
|
||||
.length,
|
||||
);
|
||||
assert.equal(columns, width >= 1024 ? 4 : width >= 640 ? 2 : 1);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
false,
|
||||
);
|
||||
for (const [href, title, description] of destinations) {
|
||||
const link = section.getByRole('link', {
|
||||
name: title,
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(await link.getAttribute('href'), href);
|
||||
assert.equal(
|
||||
(await link.locator('p').textContent()).trim(),
|
||||
description,
|
||||
);
|
||||
}
|
||||
assert.equal(await page.locator('.tempo-header').count(), 1);
|
||||
assert.equal(await page.locator('.portal-hero').count(), 1);
|
||||
await section.screenshot({
|
||||
path: path.join(dir, `cards-${width}-dpr${dpr}.png`),
|
||||
});
|
||||
if (width === 1440) {
|
||||
for (const [href, title] of destinations) {
|
||||
const link = section.getByRole('link', {
|
||||
name: title,
|
||||
exact: true,
|
||||
});
|
||||
await link.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL(base + href);
|
||||
assert.equal(navigation.at(-1), href);
|
||||
await page.goto(base + fixturePath, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
await section.waitFor();
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
width,
|
||||
dpr,
|
||||
columns,
|
||||
photos: photos.map(({ src, width, height }) => ({
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
})),
|
||||
status: 'passed',
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
for (const fail of ['primary', 'all']) {
|
||||
const { context, page, section, releaseImages } = await createCase(
|
||||
390,
|
||||
2,
|
||||
fail,
|
||||
);
|
||||
releaseImages();
|
||||
for (const link of await section.getByRole('link').all())
|
||||
await link.scrollIntoViewIfNeeded();
|
||||
if (fail === 'primary') {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
'#investment-context-heading + nav img',
|
||||
),
|
||||
].length === 5 &&
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
'#investment-context-heading + nav img',
|
||||
),
|
||||
].every(
|
||||
(image) =>
|
||||
image.complete &&
|
||||
image.naturalWidth > 0 &&
|
||||
image.currentSrc.includes('-fallback'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await section
|
||||
.getByText('Imagem indisponível', { exact: true })
|
||||
.nth(4)
|
||||
.waitFor();
|
||||
assert.equal(await section.getByRole('link').count(), 5);
|
||||
assert.equal(await section.locator('[role="img"]').count(), 5);
|
||||
}
|
||||
await section.screenshot({
|
||||
path: path.join(dir, `fallback-${fail}.png`),
|
||||
});
|
||||
results.push({ fail, status: 'passed' });
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'qa-results.json'),
|
||||
JSON.stringify(
|
||||
{ results, errors, navigation, databaseAccess: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify({ cases: results.length, errors, navigation }));
|
||||
} catch (error) {
|
||||
if (currentPage && !currentPage.isClosed()) {
|
||||
await Promise.all([
|
||||
Promise.resolve(
|
||||
currentPage.screenshot({ path: path.join(dir, 'failure.png') }),
|
||||
),
|
||||
Promise.resolve(currentPage.content()).then((html) =>
|
||||
fs.writeFileSync(path.join(dir, 'failure.html'), html),
|
||||
),
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Real portal components in Vite, synthetic public data, no Laravel/database access.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(
|
||||
process.env.HERO_QA_PLAYWRIGHT ||
|
||||
'C:/Users/jorge/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright',
|
||||
);
|
||||
const root = process.cwd();
|
||||
const base = process.env.HERO_QA_VITE || 'http://127.0.0.1:5173';
|
||||
assert.equal(new URL(base).hostname, '127.0.0.1');
|
||||
const dir = path.join(root, 'outputs/portal-hero-no-controls-20261023');
|
||||
const before = process.argv.includes('--before');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (before) assert.ok(!fs.existsSync(path.join(dir, 'before-results.json')));
|
||||
const fixtureProps = {
|
||||
auth: { user: null, canManagePortal: false },
|
||||
essentialResources: [],
|
||||
countries: [],
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.html'),
|
||||
`<!doctype html>
|
||||
<html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Portal QA</title></head>
|
||||
<body><div id="app"></div><script type="module" src="/outputs/portal-hero-no-controls-20261023/fixture.tsx"></script></body></html>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.tsx'),
|
||||
`
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import Portal from '../../resources/js/pages/PortalInvestidor';
|
||||
import PortalNavigation from '../../resources/js/layouts/portal-navigation';
|
||||
import '../../resources/css/app.css';
|
||||
await createInertiaApp({page:{component:'PortalInvestidor',props:${JSON.stringify(fixtureProps)},url:'/',version:null},
|
||||
resolve:()=>Portal,layout:()=>PortalNavigation,setup({el,App,props}){createRoot(el).render(<App {...props}/>);}});
|
||||
`,
|
||||
);
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HERO_QA_CHROMIUM
|
||||
? { executablePath: process.env.HERO_QA_CHROMIUM }
|
||||
: {}),
|
||||
});
|
||||
const results = [];
|
||||
const errors = [];
|
||||
const requests = [];
|
||||
let activePage;
|
||||
try {
|
||||
const cases = before
|
||||
? [
|
||||
[1440, 'reduce'],
|
||||
[390, 'reduce'],
|
||||
]
|
||||
: [
|
||||
[320, 'reduce'],
|
||||
[390, 'reduce'],
|
||||
[768, 'reduce'],
|
||||
[1440, 'reduce'],
|
||||
[1920, 'reduce'],
|
||||
[390, 'no-preference'],
|
||||
[1440, 'no-preference'],
|
||||
];
|
||||
for (const [width, reducedMotion] of cases) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 1000 },
|
||||
reducedMotion,
|
||||
});
|
||||
await context.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== base) return route.abort();
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
const file = path.resolve(root, 'public', `.${url.pathname}`);
|
||||
assert.ok(
|
||||
file.startsWith(
|
||||
path.join(root, 'public', 'images') + path.sep,
|
||||
),
|
||||
);
|
||||
return fs.existsSync(file)
|
||||
? route.fulfill({ path: file })
|
||||
: route.fulfill({ status: 404 });
|
||||
}
|
||||
if (url.pathname === '/api/v1/home')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
success: true,
|
||||
data: {
|
||||
sectors: [
|
||||
{
|
||||
slug: 'energia',
|
||||
name: 'Energia',
|
||||
shortDescription: 'Energia de demonstração',
|
||||
opportunitiesCount: 6,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
opportunities: 6,
|
||||
sectors: 1,
|
||||
provinces: 3,
|
||||
},
|
||||
featuredOpportunities: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (url.pathname.startsWith('/api/'))
|
||||
return route.fulfill({
|
||||
json: { data: [], total: 0, current_page: 1, last_page: 1 },
|
||||
});
|
||||
if (route.request().headers()['x-inertia']) {
|
||||
requests.push(url.pathname + url.search);
|
||||
return route.fulfill({
|
||||
headers: { 'X-Inertia': 'true' },
|
||||
json: {
|
||||
component: 'PortalInvestidor',
|
||||
props: {
|
||||
...fixtureProps,
|
||||
initialView: 'opportunities',
|
||||
initialQuery: url.searchParams.get('q') || '',
|
||||
},
|
||||
url: url.pathname + url.search,
|
||||
version: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
activePage = page;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(
|
||||
`${base}/outputs/portal-hero-no-controls-20261023/fixture.html`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
const search = page.locator('#portal-opportunity-search');
|
||||
await search.waitFor();
|
||||
await page.getByText('6', { exact: true }).first().waitFor();
|
||||
assert.equal(await page.locator('h1').count(), 1);
|
||||
assert.equal(await page.locator('.tempo-header').count(), 1);
|
||||
await page
|
||||
.locator('.portal-hero img')
|
||||
.evaluateAll((images) =>
|
||||
Promise.all(images.map((image) => image.decode())),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
dir,
|
||||
`${before ? 'before' : 'after'}-${width}-${reducedMotion}.png`,
|
||||
),
|
||||
fullPage: false,
|
||||
});
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
false,
|
||||
`overflow at ${width}`,
|
||||
);
|
||||
assert.ok(
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: /^Documentos e Formulários/,
|
||||
})
|
||||
.count(),
|
||||
);
|
||||
if (!before) {
|
||||
const hero = page.getByRole('region', {
|
||||
name: 'INVISTA EM MOÇAMBIQUE',
|
||||
});
|
||||
assert.equal(
|
||||
await hero
|
||||
.getByRole('group', { name: 'Imagens de apresentação' })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await hero
|
||||
.getByRole('button', {
|
||||
name: /Mostrar imagem|Pausar apresentação|Retomar apresentação/,
|
||||
})
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await hero.getByRole('link', { name: 'Termos de uso' }).count(),
|
||||
0,
|
||||
);
|
||||
const images = hero.locator('img');
|
||||
assert.equal(await images.count(), 2);
|
||||
assert.ok(
|
||||
await images.evaluateAll((nodes) =>
|
||||
nodes.every(
|
||||
(image) => image.complete && image.naturalWidth > 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
const fullTitle = 'INVISTA EM MOÇAMBIQUE';
|
||||
const fullSubtitle =
|
||||
'Transforme informação em decisões e ideias em negócios reais.';
|
||||
const title = hero.locator('h1 [data-hero-typed]');
|
||||
const subtitle = hero.locator('p [data-hero-typed]');
|
||||
if (reducedMotion === 'no-preference') {
|
||||
await search.focus();
|
||||
const positionBefore = await search.boundingBox();
|
||||
await page.waitForFunction(
|
||||
({ heading, tagline }) =>
|
||||
document.querySelector('h1 [data-hero-typed]')
|
||||
.textContent === heading &&
|
||||
document.querySelector('p [data-hero-typed]')
|
||||
.textContent === tagline,
|
||||
{ heading: fullTitle, tagline: fullSubtitle },
|
||||
);
|
||||
const positionAfter = await search.boundingBox();
|
||||
assert.ok(
|
||||
Math.abs(positionAfter.y - positionBefore.y) < 0.5,
|
||||
'Search moved during typing',
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(dir, 'typed-' + width + '.png'),
|
||||
});
|
||||
const active = await images.evaluateAll((nodes) =>
|
||||
nodes.findIndex((node) =>
|
||||
node.classList.contains('opacity-100'),
|
||||
),
|
||||
);
|
||||
await page.waitForTimeout(8300);
|
||||
assert.equal(
|
||||
await images.evaluateAll((nodes) =>
|
||||
nodes.findIndex((node) =>
|
||||
node.classList.contains('opacity-100'),
|
||||
),
|
||||
),
|
||||
active,
|
||||
);
|
||||
await search.evaluate((element) => element.blur());
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForFunction(
|
||||
(previous) =>
|
||||
[
|
||||
...document.querySelectorAll('.portal-hero img'),
|
||||
].findIndex((image) =>
|
||||
image.classList.contains('opacity-100'),
|
||||
) !== previous,
|
||||
active,
|
||||
{ timeout: 12000 },
|
||||
);
|
||||
assert.ok(
|
||||
(await title.textContent()).length < fullTitle.length,
|
||||
'Automatic slide did not replay typing',
|
||||
);
|
||||
await page.waitForFunction(
|
||||
({ heading, tagline }) =>
|
||||
document.querySelector('h1 [data-hero-typed]')
|
||||
.textContent === heading &&
|
||||
document.querySelector('p [data-hero-typed]')
|
||||
.textContent === tagline,
|
||||
{ heading: fullTitle, tagline: fullSubtitle },
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(dir, 'second-image-' + width + '.png'),
|
||||
});
|
||||
} else {
|
||||
assert.equal(await title.textContent(), fullTitle);
|
||||
assert.equal(await subtitle.textContent(), fullSubtitle);
|
||||
assert.equal(
|
||||
await hero.locator('.portal-hero-caret').count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Exercise the real parent handler and Inertia request with accents and punctuation.
|
||||
await search.fill(' energia & Nampula / Moçambique ');
|
||||
if (!before && width === 390) {
|
||||
await search.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Pesquisar', exact: true })
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
} else if (!before && width === 768) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Pesquisar', exact: true })
|
||||
.click();
|
||||
} else {
|
||||
await search.press('Enter');
|
||||
}
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Oportunidades de investimento',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
const last = new URL(requests.at(-1), base);
|
||||
assert.equal(last.pathname, '/oportunidades');
|
||||
assert.equal(
|
||||
last.searchParams.get('q'),
|
||||
before
|
||||
? ' energia & Nampula / Moçambique '
|
||||
: 'energia & Nampula / Moçambique',
|
||||
);
|
||||
if (!before && width === 390) {
|
||||
const home = `${base}/outputs/portal-hero-no-controls-20261023/fixture.html`;
|
||||
await page.goto(home, { waitUntil: 'networkidle' });
|
||||
await page.locator('#portal-opportunity-search').fill(' ');
|
||||
await page
|
||||
.getByRole('button', { name: 'Pesquisar', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Oportunidades de investimento',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
new URL(requests.at(-1), base).searchParams.get('q'),
|
||||
'',
|
||||
);
|
||||
await page.goto(home, { waitUntil: 'networkidle' });
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Consultar oportunidades',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Oportunidades de investimento',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
new URL(requests.at(-1), base).pathname,
|
||||
'/oportunidades',
|
||||
);
|
||||
await context.route('**/api/v1/home', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { message: 'Synthetic failure' },
|
||||
}),
|
||||
);
|
||||
await page.goto(home, { waitUntil: 'networkidle' });
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.deepEqual(
|
||||
await page
|
||||
.locator('dl[aria-label="Indicadores do portal"] dd')
|
||||
.allTextContents(),
|
||||
['—', '—', '—'],
|
||||
);
|
||||
await context.route('**/api/v1/home', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
success: true,
|
||||
data: {
|
||||
sectors: [],
|
||||
featuredOpportunities: [],
|
||||
stats: {
|
||||
opportunities: 0,
|
||||
sectors: 0,
|
||||
provinces: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(home, { waitUntil: 'networkidle' });
|
||||
assert.deepEqual(
|
||||
await page
|
||||
.locator('dl[aria-label="Indicadores do portal"] dd')
|
||||
.allTextContents(),
|
||||
['0', '0', '0'],
|
||||
);
|
||||
}
|
||||
results.push({ width, reducedMotion, status: 'passed' });
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, before ? 'before-results.json' : 'qa-results.json'),
|
||||
JSON.stringify(
|
||||
{ results, errors, requests, databaseAccess: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify({ results, errors }));
|
||||
} catch (error) {
|
||||
if (activePage && !activePage.isClosed()) {
|
||||
await activePage.screenshot({ path: path.join(dir, 'failure.png') });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'failure.html'),
|
||||
await activePage.content(),
|
||||
);
|
||||
console.error(
|
||||
JSON.stringify(
|
||||
await activePage.locator('h1').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
text: node.textContent,
|
||||
html: node.outerHTML,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
console.error(JSON.stringify(errors));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Public React/Inertia screens with synthetic sectors and local assets; no database.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(
|
||||
process.env.SECTORS_QA_PLAYWRIGHT ||
|
||||
'C:/Users/jorge/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright',
|
||||
);
|
||||
const root = process.cwd();
|
||||
const base = (
|
||||
process.env.SECTORS_QA_VITE || fs.readFileSync('public/hot', 'utf8')
|
||||
).trim();
|
||||
assert.equal(new URL(base).hostname, '127.0.0.1');
|
||||
const dir = path.join(root, 'outputs/home-sectors-20261023');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const sectors = [
|
||||
[
|
||||
'agronegocio',
|
||||
'Agronegócio',
|
||||
'Produção, agroprocessamento, irrigação e cadeias de valor agrícolas.',
|
||||
],
|
||||
[
|
||||
'turismo',
|
||||
'Turismo',
|
||||
'Hotelaria, turismo costeiro, cultural e ecológico.',
|
||||
],
|
||||
[
|
||||
'infraestruturas',
|
||||
'Infraestruturas',
|
||||
'Logística, transportes, parques industriais e energia.',
|
||||
],
|
||||
[
|
||||
'economia-azul',
|
||||
'Economia Azul',
|
||||
'Pesca, aquacultura, portos e logística marítima.',
|
||||
],
|
||||
[
|
||||
'mercado-financeiro',
|
||||
'Mercado Financeiro',
|
||||
'Fintech, capital de risco e instrumentos financeiros.',
|
||||
],
|
||||
['mineracao', 'Mineração', null],
|
||||
['energia-especifica', 'Energia', null],
|
||||
['imobiliario', 'Imobiliário', null],
|
||||
['bens-de-consumo-e-franquias', 'Bens de Consumo e Franquias', null],
|
||||
['tecnologias-de-informacao', 'Tecnologias de Informação', null],
|
||||
].map(([slug, name, description], index) => ({
|
||||
id: index + 1,
|
||||
slug,
|
||||
name,
|
||||
shortDescription: description,
|
||||
short_description: description,
|
||||
description,
|
||||
opportunitiesCount: index === 4 ? 4 : 0,
|
||||
opportunities_count: index === 4 ? 4 : 0,
|
||||
url: `/sectores/${slug}`,
|
||||
specializations:
|
||||
index === 4
|
||||
? [
|
||||
{
|
||||
id: 901,
|
||||
name: 'Mercado Financeiro — Fintech',
|
||||
slug: 'fintech',
|
||||
url: '/sectores/mercado-financeiro/fintech',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
const fixturePath = '/outputs/home-sectors-20261023/fixture.html';
|
||||
const html =
|
||||
'<!doctype html><html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sectores QA</title></head><body><div id="app"></div><script type="module" src="/outputs/home-sectors-20261023/fixture.tsx"></script></body></html>';
|
||||
fs.writeFileSync(path.join(dir, 'fixture.html'), html);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.tsx'),
|
||||
`
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {createInertiaApp} from '@inertiajs/react';
|
||||
import Portal from '../../resources/js/pages/PortalInvestidor';
|
||||
import Services from '../../resources/js/pages/portal/services';
|
||||
import Navigation from '../../resources/js/layouts/portal-navigation';
|
||||
import '../../resources/css/app.css';
|
||||
const sectors=${JSON.stringify(sectors)};
|
||||
const url=new URL(location.href),catalogue=url.pathname==='/sectores',detail=url.pathname.startsWith('/sectores/');
|
||||
const selected=sectors.find(sector=>sector.url===url.pathname)||{id:901,name:'Fintech',slug:'fintech',description:null,short_description:null};
|
||||
const props={auth:{user:null,canManagePortal:false},countries:[],essentialResources:[],...(catalogue?{section:'sectors',sectors:url.searchParams.has('empty')?[]:sectors}:detail?{section:'sector',sector:selected,opportunities:[]}:{} )};
|
||||
await createInertiaApp({page:{component:catalogue||detail?'portal/services':'PortalInvestidor',props,url:url.pathname+url.search,version:null},resolve:()=>catalogue||detail?Services:Portal,layout:()=>Navigation,setup({el,App,props}){createRoot(el).render(<App {...props}/>);}});
|
||||
`,
|
||||
);
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.SECTORS_QA_CHROMIUM
|
||||
? { executablePath: process.env.SECTORS_QA_CHROMIUM }
|
||||
: {}),
|
||||
});
|
||||
// Full navigations need Vite's transformed HTML, including the React preamble.
|
||||
const fixtureResponse = await fetch(base + fixturePath);
|
||||
assert.ok(fixtureResponse.ok);
|
||||
const navigationHtml = await fixtureResponse.text();
|
||||
const errors = [],
|
||||
results = [],
|
||||
navigation = [];
|
||||
let currentPage;
|
||||
async function openCase(width, { empty = false, fail = 'none' } = {}) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 1000 },
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
await context.routeWebSocket('**/*', () => {});
|
||||
await context.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== base) return route.abort();
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
if (
|
||||
url.pathname.startsWith('/images/sectors/') &&
|
||||
(fail === 'all' ||
|
||||
(fail === 'primary' &&
|
||||
!url.pathname.includes('outros-sectores')))
|
||||
)
|
||||
return route.fulfill({ status: 404 });
|
||||
const file = path.resolve(root, 'public', '.' + url.pathname);
|
||||
assert.ok(
|
||||
file.startsWith(path.join(root, 'public', 'images') + path.sep),
|
||||
);
|
||||
return fs.existsSync(file)
|
||||
? route.fulfill({ path: file })
|
||||
: route.fulfill({ status: 404 });
|
||||
}
|
||||
if (url.pathname === '/api/v1/home')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
success: true,
|
||||
data: {
|
||||
sectors: empty ? [] : sectors,
|
||||
featuredOpportunities: [],
|
||||
stats: {
|
||||
opportunities: 4,
|
||||
sectors: empty ? 0 : 10,
|
||||
provinces: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (url.pathname.startsWith('/api/'))
|
||||
return route.fulfill({ json: { data: [] } });
|
||||
if (
|
||||
route.request().isNavigationRequest() &&
|
||||
(url.pathname === '/sectores' ||
|
||||
url.pathname.startsWith('/sectores/') ||
|
||||
url.pathname === '/')
|
||||
) {
|
||||
navigation.push(url.pathname);
|
||||
return route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: navigationHtml,
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
currentPage = page;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base + fixturePath, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60000,
|
||||
});
|
||||
return { context, page };
|
||||
}
|
||||
async function loadImages(region) {
|
||||
for (const image of await region.locator('img').all()) {
|
||||
await image.scrollIntoViewIfNeeded();
|
||||
await region
|
||||
.page()
|
||||
.waitForFunction(
|
||||
(node) => node.complete && node.naturalWidth > 0,
|
||||
await image.elementHandle(),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const width of [320, 390, 768, 1024, 1440, 1920]) {
|
||||
const { context, page } = await openCase(width);
|
||||
const section = page.getByRole('region', {
|
||||
name: 'Sectores de investimento',
|
||||
exact: true,
|
||||
});
|
||||
await section.waitFor();
|
||||
await loadImages(section);
|
||||
assert.equal(await section.getByRole('article').count(), 5);
|
||||
assert.deepEqual(
|
||||
await section.locator('h3').allTextContents(),
|
||||
sectors.slice(0, 5).map((s) => s.name),
|
||||
);
|
||||
assert.equal(
|
||||
await section
|
||||
.getByRole('link', { name: /^Ler mais sobre / })
|
||||
.count(),
|
||||
5,
|
||||
);
|
||||
assert.ok(
|
||||
await section.getByText('0 oportunidades', { exact: true }).count(),
|
||||
);
|
||||
assert.equal(
|
||||
await section.getByText('4 oportunidades', { exact: true }).count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await section
|
||||
.getByRole('link', { name: 'Especialização: Fintech' })
|
||||
.getAttribute('href'),
|
||||
'/sectores/mercado-financeiro/fintech',
|
||||
);
|
||||
const headings = await page.locator('main h2').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
title: node.textContent.trim(),
|
||||
size: getComputedStyle(node).fontSize,
|
||||
weight: getComputedStyle(node).fontWeight,
|
||||
color: getComputedStyle(node).color,
|
||||
lineHeight: getComputedStyle(node).lineHeight,
|
||||
})),
|
||||
);
|
||||
assert.equal(headings.length, 6);
|
||||
headings.forEach((heading) => {
|
||||
assert.equal(heading.color, 'rgb(237, 28, 36)');
|
||||
assert.equal(heading.size, '24px');
|
||||
assert.equal(heading.weight, '700');
|
||||
assert.equal(heading.lineHeight, '32px');
|
||||
});
|
||||
const homeColumns = await section
|
||||
.locator(':scope > div')
|
||||
.first()
|
||||
.locator(':scope > div')
|
||||
.evaluate(
|
||||
(node) =>
|
||||
getComputedStyle(node).gridTemplateColumns.split(' ')
|
||||
.length,
|
||||
);
|
||||
assert.equal(homeColumns, width >= 1024 ? 5 : width >= 640 ? 3 : 2);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
false,
|
||||
);
|
||||
await section.screenshot({
|
||||
path: path.join(dir, `home-sectors-${width}.png`),
|
||||
});
|
||||
if (width === 1440)
|
||||
await page.screenshot({
|
||||
path: path.join(dir, 'homepage-1440.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
const more = section.getByRole('link', {
|
||||
name: 'Ver mais sectores',
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(await more.getAttribute('href'), '/sectores');
|
||||
await more.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL(base + '/sectores');
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Sectores de investimento',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
const catalogue = page.getByRole('region', {
|
||||
name: 'Todos os sectores de investimento',
|
||||
});
|
||||
await loadImages(catalogue);
|
||||
assert.equal(await catalogue.getByRole('article').count(), 10);
|
||||
assert.deepEqual(
|
||||
await catalogue.locator('h3').allTextContents(),
|
||||
sectors.map((s) => s.name),
|
||||
);
|
||||
assert.equal(
|
||||
await catalogue
|
||||
.getByRole('link', { name: /^Ler mais sobre / })
|
||||
.count(),
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
false,
|
||||
);
|
||||
await catalogue.screenshot({
|
||||
path: path.join(dir, `catalogue-${width}.png`),
|
||||
});
|
||||
if (width === 1440) {
|
||||
for (const sector of sectors) {
|
||||
await catalogue
|
||||
.getByRole('link', {
|
||||
name: `Ler mais sobre ${sector.name}`,
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page.waitForURL(base + sector.url);
|
||||
await page
|
||||
.getByRole('heading', { name: sector.name, exact: true })
|
||||
.waitFor();
|
||||
await page.goto(base + '/sectores', {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
await catalogue.waitFor();
|
||||
}
|
||||
await catalogue
|
||||
.getByRole('link', { name: 'Especialização: Fintech' })
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
base + '/sectores/mercado-financeiro/fintech',
|
||||
);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Fintech', exact: true })
|
||||
.waitFor();
|
||||
await page.goto(base + '/sectores', { waitUntil: 'networkidle' });
|
||||
await catalogue
|
||||
.getByRole('link', { name: 'Voltar ao início' })
|
||||
.click();
|
||||
await page.waitForURL(base + '/');
|
||||
await page
|
||||
.getByRole('region', {
|
||||
name: 'Sectores de investimento',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
}
|
||||
results.push({
|
||||
width,
|
||||
homeSectors: 5,
|
||||
catalogueSectors: 10,
|
||||
homeColumns,
|
||||
headings,
|
||||
status: 'passed',
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
for (const fail of ['primary', 'all']) {
|
||||
const { context, page } = await openCase(390, { fail });
|
||||
const section = page.getByRole('region', {
|
||||
name: 'Sectores de investimento',
|
||||
exact: true,
|
||||
});
|
||||
await section.waitFor();
|
||||
for (const article of await section.getByRole('article').all())
|
||||
await article.scrollIntoViewIfNeeded();
|
||||
await page.waitForFunction((mode) => {
|
||||
const cards = [
|
||||
...document.querySelectorAll(
|
||||
'#home-sectors-heading ~ div article',
|
||||
),
|
||||
];
|
||||
return (
|
||||
cards.length === 5 &&
|
||||
cards.every((card) =>
|
||||
mode === 'all'
|
||||
? !card.querySelector('img')
|
||||
: card.querySelector('img')?.complete &&
|
||||
card.querySelector('img')?.naturalWidth > 0 &&
|
||||
card
|
||||
.querySelector('img')
|
||||
?.currentSrc.includes('outros-sectores'),
|
||||
)
|
||||
);
|
||||
}, fail);
|
||||
assert.equal(
|
||||
await section
|
||||
.getByRole('link', { name: /^Ler mais sobre / })
|
||||
.count(),
|
||||
5,
|
||||
);
|
||||
await section.screenshot({
|
||||
path: path.join(dir, `fallback-${fail}.png`),
|
||||
});
|
||||
results.push({ fail, status: 'passed' });
|
||||
await context.close();
|
||||
}
|
||||
const { context, page } = await openCase(390, { empty: true });
|
||||
const section = page.getByRole('region', {
|
||||
name: 'Sectores de investimento',
|
||||
exact: true,
|
||||
});
|
||||
await section.getByText('Não existem sectores publicados.').waitFor();
|
||||
assert.equal(await section.getByRole('article').count(), 0);
|
||||
await page.goto(base + '/sectores?empty=1', { waitUntil: 'networkidle' });
|
||||
await page.getByText('Não existem sectores publicados.').waitFor();
|
||||
results.push({ emptyCatalogue: true, status: 'passed' });
|
||||
await context.close();
|
||||
assert.deepEqual(errors, []);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'qa-results.json'),
|
||||
JSON.stringify(
|
||||
{ results, errors, navigation, databaseAccess: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify({ cases: results.length, errors, navigation }));
|
||||
} catch (error) {
|
||||
if (currentPage && !currentPage.isClosed())
|
||||
await Promise.all([
|
||||
Promise.resolve(
|
||||
currentPage.screenshot({ path: path.join(dir, 'failure.png') }),
|
||||
),
|
||||
Promise.resolve(currentPage.content()).then((html) =>
|
||||
fs.writeFileSync(path.join(dir, 'failure.html'), html),
|
||||
),
|
||||
]);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||