organizacao da tela inicial

This commit is contained in:
2026-10-23 14:51:34 +02:00
parent e2b0fb4b2d
commit ec4b2f2af2
63 changed files with 3440 additions and 295 deletions
@@ -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>
);
}
@@ -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>
);
}
+3 -2
View File
@@ -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>
+1
View File
@@ -1,4 +1,5 @@
export type Sector = {
url?: string;
slug: string;
name: string;
specializations?: { id: number; name: string; slug: string; url: string }[];
+10
View File
@@ -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]';
+223 -258
View File
@@ -1,3 +1,11 @@
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';
@@ -8,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,
@@ -31,28 +38,20 @@ import type {
Sector,
} from '@/lib/portal-api';
import {
Leaf,
Zap,
Hotel,
Truck,
Waves,
Banknote,
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,
@@ -67,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>
@@ -76,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
@@ -136,65 +135,6 @@ function TopBar({
);
}
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,
@@ -284,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>
@@ -597,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);
@@ -671,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);
@@ -697,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>
);
}
@@ -945,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>
);
@@ -1015,10 +976,6 @@ export default function PortalInvestidorApp({
router.get('/oportunidades', { q: heroQuery.trim() });
}
function handleSelectSector(slug: string) {
router.get('/sectores/' + encodeURIComponent(slug));
}
function handleView(opportunity: Opportunity) {
router.get('/oportunidades/' + encodeURIComponent(opportunity.slug));
}
@@ -1032,7 +989,9 @@ 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' && (
@@ -1044,109 +1003,38 @@ export default function PortalInvestidorApp({
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 && (
@@ -1167,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>
</>
)}
@@ -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>
);
}
+38 -32
View File
@@ -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 && (
<>