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
+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 && (
<>