Files
Investidor-V2/resources/js/pages/PortalInvestidor.tsx
T

1405 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SearchState from '@/components/search-state';
import { useInterestDraft } from '@/hooks/use-interest-draft';
import { createContext, useContext, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
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,
fieldErrors,
interestPayload,
money,
portalRequest,
searchUrl,
toFeaturedOpportunity,
toOpportunity,
} from '@/lib/portal-api';
import type {
Filters,
FormErrors,
HomeResponse,
InterestFormData,
Opportunity,
OpportunityRecord,
SearchResponse,
Sector,
} from '@/lib/portal-api';
import {
Search,
Leaf,
Zap,
Hotel,
Truck,
Waves,
Banknote,
ArrowRight,
ArrowLeft,
MapPin,
CheckCircle2,
} 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,
error,
retry,
}: {
loading: boolean;
error?: string;
retry: () => void;
}) {
if (loading)
return (
<p
role="status"
className="mx-auto max-w-6xl px-5 py-8 text-slate-600"
>
A carregar dados...
</p>
);
if (!error) return null;
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"
>
<p>{error}</p>
<button
type="button"
onClick={retry}
className="mt-3 font-bold underline"
>
Tentar novamente
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Componentes */
/* ------------------------------------------------------------------ */
function TopBar({
onNavigate,
requestCount,
}: {
onNavigate: (view: View) => void;
requestCount: number;
}) {
return (
<nav
aria-label="Navegação das manifestações"
className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur"
>
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-4">
<button
onClick={() => onNavigate('home')}
className="flex items-baseline gap-1 font-black tracking-tight text-slate-900"
>
<span className="text-lg">INVESTE</span>
<span className="text-lg text-emerald-700">Moçambique</span>
</button>
<div className="flex items-center gap-3 text-xs text-slate-500">
<a href="/investidor/manifestacoes" className="underline">
O meu painel
</a>
<span className="hidden sm:inline">
{requestCount} pedido{requestCount === 1 ? '' : 's'}{' '}
recebido
{requestCount === 1 ? '' : 's'}
</span>
<button
onClick={() => onNavigate('interest')}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-bold text-white hover:bg-slate-700"
>
Manifestar interesse
</button>
</div>
</div>
</nav>
);
}
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,
onInterest,
}: {
opportunity: Opportunity;
onView: OpportunityAction;
onInterest: OpportunityAction;
}) {
return (
<article className="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap gap-2">
{opportunity.featured && (
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">
Destaque
</span>
)}
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
{opportunity.sectorName}
</span>
</div>
<p className="mt-3 text-xs font-semibold tracking-wide text-slate-400 uppercase">
{opportunity.reference}
</p>
<h3 className="mt-1 text-lg font-bold text-slate-900">
{opportunity.title}
</h3>
<p className="mt-2 flex items-center gap-1 text-sm text-slate-500">
<MapPin size={14} />{' '}
{opportunity.province ?? 'Localização por indicar'}
</p>
<dl className="mt-4 space-y-2 border-t border-slate-100 pt-4 text-sm">
<div className="flex justify-between">
<dt className="text-slate-500">Investimento</dt>
<dd className="font-semibold text-slate-800">
{money(opportunity.amount, opportunity.currency)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">Maturidade</dt>
<dd className="font-semibold text-slate-800">
{opportunity.maturity ?? 'Por indicar'}
</dd>
</div>
</dl>
<div className="mt-6 flex gap-2">
<button
onClick={() => onView(opportunity)}
className="flex-1 rounded-lg bg-slate-900 py-2.5 text-sm font-bold text-white hover:bg-slate-700"
>
Ver ficha
</button>
<button
onClick={() => onInterest(opportunity)}
className="flex-1 rounded-lg border border-slate-300 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50"
>
Manifestar interesse
</button>
</div>
</article>
);
}
function OpportunitiesView({
filters,
setFilters,
onView,
onInterest,
}: {
filters: Filters;
setFilters: Dispatch<SetStateAction<Filters>>;
onView: OpportunityAction;
onInterest: OpportunityAction;
}) {
const sectors = useContext(SectorContext);
const [page, setPage] = useState(1);
const query = usePortalQuery<SearchResponse>(searchUrl(filters, page), 250);
const results = query.data?.data.map(toOpportunity) ?? [];
const total = query.data?.total ?? 0;
function changeFilters(next: Filters) {
setFilters(next);
setPage(1);
}
return (
<section className="mx-auto max-w-6xl px-5 py-12">
<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>
<p role="status" aria-live="polite" className="mt-2 text-slate-600">
{query.loading ? (
'A pesquisar oportunidades...'
) : query.error ? (
'Pesquisa indisponível.'
) : (
<>
{total} oportunidade{total === 1 ? '' : 's'} encontrada
{total === 1 ? '' : 's'}.
</>
)}
</p>
<div className="mt-6 grid gap-3 rounded-2xl border border-slate-200 bg-white p-4 sm:grid-cols-2 lg:grid-cols-4">
<input
value={filters.query}
onChange={(e) =>
changeFilters({ ...filters, query: e.target.value })
}
placeholder="Pesquisar..."
className="rounded-lg border border-slate-200 px-3 py-2 text-sm"
/>
<select
value={filters.sector}
onChange={(e) =>
changeFilters({ ...filters, sector: e.target.value })
}
className="rounded-lg border border-slate-200 px-3 py-2 text-sm"
>
<option value="">Todos os sectores</option>
{sectors
.flatMap((s) => [
s,
...(s.specializations ?? []).map((child) => ({
...s,
slug: child.slug,
name: child.name,
specializations: [],
})),
])
.map((s) => (
<option key={s.slug} value={s.slug}>
{s.name}
</option>
))}
</select>
<ProvinceSelect
value={filters.province}
onChange={(province) =>
changeFilters({ ...filters, province })
}
placeholder="Todas as províncias"
className="rounded-lg border border-slate-200 px-3 py-2 text-sm"
/>
<select
value={filters.maturity}
onChange={(e) =>
changeFilters({ ...filters, maturity: e.target.value })
}
className="rounded-lg border border-slate-200 px-3 py-2 text-sm"
>
<option value="">Todos os estados de preparação</option>
{MATURITY_LEVELS.map((label, index) => (
<option key={label} value={index + 1}>
{label}
</option>
))}
</select>
</div>
<div className="my-4 grid gap-3 sm:grid-cols-3">
<label className="text-sm">
Dimensão: investimento mínimo
<input
aria-label="Investimento mínimo"
className="input"
type="number"
min="0"
step="0.01"
value={filters.minAmount ?? ''}
onChange={(e) =>
changeFilters({
...filters,
minAmount: e.target.value,
currency: filters.currency || 'MZN',
})
}
/>
</label>
<label className="text-sm">
Investimento máximo
<input
aria-label="Investimento máximo"
className="input"
type="number"
min="0"
step="0.01"
value={filters.maxAmount ?? ''}
onChange={(e) =>
changeFilters({
...filters,
maxAmount: e.target.value,
currency: filters.currency || 'MZN',
})
}
/>
</label>
<label className="text-sm">
Moeda
<select
className="input"
value={filters.currency ?? ''}
onChange={(e) =>
changeFilters({
...filters,
currency: e.target.value,
...(e.target.value
? {}
: { minAmount: '', maxAmount: '' }),
})
}
>
<option value="">
Todas (sem limites de montante)
</option>
{['MZN', 'USD', 'EUR', 'ZAR'].map((c) => (
<option key={c}>{c}</option>
))}
</select>
</label>
</div>
<QueryStatus
loading={query.loading}
error={query.error}
retry={query.retry}
/>
{!query.loading &&
!query.error &&
(results.length === 0 ? (
<div className="mt-10 rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-10 text-center text-slate-600">
Nenhuma oportunidade corresponde aos filtros
seleccionados.
</div>
) : (
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{results.map((o) => (
<OpportunityCard
key={o.reference}
opportunity={o}
onView={onView}
onInterest={onInterest}
/>
))}
</div>
))}
{query.data && query.data.last_page > 1 && (
<nav
aria-label="Páginas de oportunidades"
className="mt-8 flex items-center justify-center gap-5"
>
<button
type="button"
disabled={page <= 1 || query.loading}
onClick={() => setPage((value) => value - 1)}
className="rounded-lg border px-4 py-2 disabled:opacity-40"
>
Anterior
</button>
<span>
Página {query.data.current_page} de{' '}
{query.data.last_page}
</span>
<button
type="button"
disabled={page >= query.data.last_page || query.loading}
onClick={() => setPage((value) => value + 1)}
className="rounded-lg border px-4 py-2 disabled:opacity-40"
>
Seguinte
</button>
</nav>
)}
</section>
);
}
function OpportunityDetail({
opportunity: selected,
onBack,
onInterest,
}: {
opportunity: Opportunity;
onBack: () => void;
onInterest: OpportunityAction;
}) {
const query = usePortalQuery<{ success: boolean; data: OpportunityRecord }>(
'/api/v1/opportunities/' + encodeURIComponent(selected.slug),
);
if (!query.data)
return (
<section className="mx-auto max-w-3xl px-5 py-12">
<button
type="button"
onClick={onBack}
className="mb-6 font-semibold"
>
← Voltar às oportunidades
</button>
<QueryStatus
loading={query.loading}
error={query.error}
retry={query.retry}
/>
</section>
);
const opportunity = toOpportunity(query.data.data);
return (
<section className="mx-auto max-w-3xl px-5 py-12">
<button
onClick={onBack}
className="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900"
>
<ArrowLeft size={16} /> Voltar às oportunidades
</button>
<p className="text-xs font-semibold tracking-wide text-slate-400 uppercase">
{opportunity.reference}
</p>
<h1 className="mt-1 text-3xl font-black text-slate-900">
{opportunity.title}
</h1>
<p className="mt-2 flex items-center gap-2 text-sm text-slate-500">
<MapPin size={14} />{' '}
{opportunity.province ?? 'Localização por indicar'} ·{' '}
{opportunity.sectorName}
</p>
<p className="mt-6 leading-relaxed text-slate-700">
{opportunity.description ??
opportunity.summary ??
'Descrição por indicar'}
</p>
<dl className="mt-8 grid gap-4 rounded-2xl border border-slate-200 bg-white p-6 sm:grid-cols-2">
<div>
<dt className="text-xs text-slate-400 uppercase">
Investimento estimado
</dt>
<dd className="mt-1 font-bold text-slate-900">
{money(opportunity.amount, opportunity.currency)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400 uppercase">
Maturidade
</dt>
<dd className="mt-1 font-bold text-slate-900">
{opportunity.maturity ?? 'Por indicar'}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400 uppercase">
Financiamento pretendido
</dt>
<dd className="mt-1 font-bold text-slate-900">
{opportunity.financing ?? 'Por indicar'}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400 uppercase">
Impacto esperado
</dt>
<dd className="mt-1 font-bold text-slate-900">
{opportunity.impact ?? 'Por indicar'}
</dd>
</div>
</dl>
<div className="mt-6 rounded-xl bg-amber-50 p-4 text-sm text-amber-900">
A informação legal, fiscal e financeira apresentada é
orientadora e deve ser confirmada junto das entidades
competentes antes da tomada de decisão.
</div>
<button
onClick={() => onInterest(opportunity)}
className="mt-8 rounded-lg bg-slate-900 px-6 py-3 font-bold text-white hover:bg-slate-700"
>
Manifestar interesse nesta oportunidade
</button>
</section>
);
}
function InterestForm({
preselected,
onSubmitted,
countries,
}: {
preselected: Opportunity | null;
onSubmitted: () => void;
countries: Country[];
}) {
const sectors = useContext(SectorContext);
const [submitting, setSubmitting] = useState(false);
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 { form, setForm } = draft;
const [errors, setErrors] = useState<FormErrors>({});
const [result, setResult] = useState<{ reference: string } | null>(null);
function update<K extends keyof InterestFormData>(
field: K,
value: InterestFormData[K],
) {
setForm((prev) => ({ ...prev, [field]: value }));
}
function validate() {
const next: FormErrors = {};
if (form.name.trim().length < 3) next.name = 'Indique o nome completo.';
if (!/^\S+@\S+\.\S+$/.test(form.email))
next.email = 'Indique um email válido.';
if (!form.country.trim()) next.country = 'Indique o país.';
if (!form.investorType)
next.investorType = 'Seleccione o tipo de investidor.';
if (!form.sectorInterest.trim())
next.sectorInterest = 'Indique o sector de interesse.';
if (form.message.trim().length < 20)
next.message = 'A mensagem deve ter pelo menos 20 caracteres.';
if (!form.privacyConsent)
next.privacyConsent =
'É necessário aceitar a política de privacidade.';
setErrors(next);
return Object.keys(next).length === 0;
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (pending.current || !validate()) return;
pending.current = true;
setSubmitting(true);
setSubmitError(null);
try {
const body = JSON.stringify(interestPayload(form));
if (submission.current?.body !== body) {
submission.current = { body, key: draft.key };
}
const response = await portalRequest<{
success: boolean;
data: { reference: string };
}>('/api/v1/interest-requests', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': submission.current.key,
},
body,
});
if (
!response.success ||
typeof response.data?.reference !== 'string'
)
throw new Error('Invalid confirmation');
draft.clear();
setResult({ reference: response.data.reference });
onSubmitted();
} catch (error: unknown) {
if (error instanceof PortalApiError && error.status === 401) { draft.authenticate(); return; }
if (error instanceof PortalApiError) {
setErrors(fieldErrors(error));
setSubmitError(error.message);
} else {
setSubmitError(
'Não foi possível confirmar o envio. Verifique a ligação antes de tentar novamente.',
);
}
} finally {
pending.current = false;
setSubmitting(false);
}
}
if (result) {
return (
<section className="mx-auto max-w-xl px-5 py-16 text-center">
<CheckCircle2 className="mx-auto text-emerald-600" size={48} />
<h1 className="mt-4 text-2xl font-black text-slate-900">
Manifestação de interesse registada
</h1>
<p className="mt-2 text-slate-600">
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>
</section>
);
}
return (
<section className="mx-auto max-w-xl px-5 py-12">
<h1 className="text-3xl font-black text-slate-900">
Manifestar interesse
</h1>
<p className="mt-2 text-slate-600">
Preencha os dados abaixo para que a equipa do portal possa
analisar o seu interesse e entrar em contacto.
</p>
{preselected && (
<div className="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
Oportunidade seleccionada:{' '}
<strong>{preselected.title}</strong> (
{preselected.reference})
</div>
)}
<form onSubmit={handleSubmit} className="mt-6">
{submitError && (
<p
role="alert"
className="mb-5 rounded-lg bg-red-50 p-3 text-sm text-red-700"
>
{submitError}
</p>
)}
<fieldset disabled={submitting} className="space-y-5">
<Field label="Nome completo" error={errors.name} required>
<input
className="input"
value={form.name}
onChange={(e) => update('name', e.target.value)}
/>
</Field>
<Field label="Email" error={errors.email} required>
<input
type="email"
className="input"
value={form.email}
onChange={(e) => update('email', e.target.value)}
/>
</Field>
<Field label="Telefone" error={errors.phone}>
<input
className="input"
value={form.phone}
onChange={(e) => update('phone', e.target.value)}
/>
</Field>
<Field
label="Tipo de investidor"
error={errors.investorType}
required
>
<select
className="input"
value={form.investorType}
onChange={(e) =>
update('investorType', e.target.value)
}
>
<option value="">Seleccione</option>
<option value="individual">
Investidor individual
</option>
<option value="empresa">Empresa</option>
<option value="fundo">Fundo de investimento</option>
<option value="entidade_publica">
Entidade pública
</option>
<option value="outro">Outro</option>
</select>
</Field>
<Field label="País" error={errors.country}>
<CountrySelect
countries={countries}
label="País do requerente"
value={form.country}
onChange={(value) => update('country', value)}
required
allowLegacyValue
/>
</Field>
<Field
label="País de origem declarado desta solicitação"
error={errors.declared_origin_country_code}
>
<CountrySelect
countries={countries}
label="País de origem declarado desta solicitação"
value={form.declared_origin_country_code ?? ''}
onChange={(value) =>
update('declared_origin_country_code', value)
}
/>
<p className="text-sm">
Origem opcional; não é deduzida do país acima nem do
IP.
</p>
</Field>
<Field
label="Sector de interesse"
error={errors.sectorInterest}
required
>
<select
className="input"
value={form.sectorInterest}
onChange={(e) =>
update('sectorInterest', e.target.value)
}
>
<option value="">Seleccione</option>
{form.sectorInterest &&
!sectors.some(
(sector) =>
sector.name === form.sectorInterest,
) && (
<option value={form.sectorInterest}>
{form.sectorInterest}
</option>
)}
{sectors
.flatMap((s) => [
s,
...(s.specializations ?? []).map(
(child) => ({
...s,
slug: child.slug,
name: child.name,
specializations: [],
}),
),
])
.map((s) => (
<option key={s.slug} value={s.name}>
{s.name}
</option>
))}
</select>
</Field>
<Field
label="Faixa de investimento"
error={errors.investmentRange}
>
<select
className="input"
value={form.investmentRange}
onChange={(e) =>
update('investmentRange', e.target.value)
}
>
<option value="">Seleccione</option>
<option value="ate-100000">Até USD 100.000</option>
<option value="100000-500000">
USD 100.000 – 500.000
</option>
<option value="500000-1000000">
USD 500.000 – 1.000.000
</option>
<option value="acima-1000000">
Acima de USD 1.000.000
</option>
</select>
</Field>
<Field label="Mensagem" error={errors.message} required>
<textarea
rows={4}
className="input"
value={form.message}
onChange={(e) => update('message', e.target.value)}
placeholder="Descreva o seu interesse ou a informação pretendida."
/>
</Field>
<label className="flex items-start gap-3 text-sm text-slate-600">
<input
type="checkbox"
checked={form.privacyConsent}
onChange={(e) =>
update('privacyConsent', e.target.checked)
}
className="mt-1"
/>
Aceito que os meus dados sejam usados para efeitos de
contacto e análise da manifestação de interesse.
</label>
{errors.privacyConsent && (
<p className="text-sm text-red-600">
{errors.privacyConsent}
</p>
)}
<button
type="submit"
className="w-full rounded-lg bg-slate-900 py-3 font-bold text-white hover:bg-slate-700"
>
{submitting
? 'A enviar...'
: 'Enviar manifestação de interesse'}
</button>
</fieldset>
</form>
</section>
);
}
function Field({
label,
required,
error,
children,
}: {
label: string;
required?: boolean;
error?: string;
children: ReactNode;
}) {
return (
<div>
<label className="block text-sm font-semibold text-slate-700">
<span className="mb-1 block">
{label}{' '}
{required && <span className="text-red-600">*</span>}
</span>
{children}
</label>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
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>
</div>
</footer>
);
}
/* ------------------------------------------------------------------ */
/* App */
/* ------------------------------------------------------------------ */
export default function PortalInvestidorApp({
initialView = 'home',
initialOpportunity,
initialSector = '',
initialQuery = '',
essentialResources = [],
countries = [],
}: {
initialView?: View;
initialOpportunity?: OpportunityRecord;
initialSector?: string;
initialQuery?: string;
essentialResources?: { id: number; title: string }[];
countries?: Country[];
}) {
const view = initialView;
const home = usePortalQuery<HomeResponse>('/api/v1/home');
const sectors = home.data?.data.sectors ?? [];
const featured =
home.data?.data.featuredOpportunities.map(toFeaturedOpportunity) ?? [];
const selected = initialOpportunity
? toOpportunity(initialOpportunity)
: null;
const [requestCount, setRequestCount] = useState(0);
const [heroQuery, setHeroQuery] = useState('');
const [filters, setFilters] = useState<Filters>({
query: initialQuery,
sector: initialSector,
province: '',
maturity: '',
});
function navigate(next: View) {
router.get(
next === 'home'
? '/'
: next === 'interest'
? '/manifestar-interesse'
: '/oportunidades',
);
}
function goToOpportunities() {
router.get('/oportunidades');
}
function handleHeroSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
router.get('/oportunidades', { q: heroQuery });
}
function handleSelectSector(slug: string) {
router.get('/sectores/' + encodeURIComponent(slug));
}
function handleView(opportunity: Opportunity) {
router.get('/oportunidades/' + encodeURIComponent(opportunity.slug));
}
function handleInterest(opportunity: Opportunity) {
router.get(
'/manifestar-interesse/' + encodeURIComponent(opportunity.slug),
);
}
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} />}
<PortalMenu />
<main>
{view === 'home' && (
<>
<Hero
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>
<QueryStatus
loading={home.loading}
error={home.error}
retry={home.retry}
/>
{home.data && (
<SectorGrid
onSelectSector={handleSelectSector}
/>
)}
<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>
<section className="border-t border-slate-200 bg-white py-16">
<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">
Oportunidades em destaque
</h2>
<button
onClick={goToOpportunities}
className="text-sm font-bold text-emerald-700 hover:underline"
>
Ver todas →
</button>
</div>
{home.data && featured.length === 0 && (
<p className="text-slate-600">
Não existem oportunidades em
destaque neste momento.
</p>
)}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{featured.map((o) => (
<OpportunityCard
key={o.reference}
opportunity={o}
onView={handleView}
onInterest={handleInterest}
/>
))}
</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>
<a
className="underline"
href="/legislacao"
>
Legislação e Incentivos
</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>
<a
className="underline"
href="/parcerias"
>
Bolsa de Parcerias
</a>{' '}
·{' '}
<a
className="underline"
href="/simulador-fiscal"
>
Simulador Fiscal
</a>
</p>
</div>
</section>
</>
)}
{view !== 'home' && home.error && (
<QueryStatus
loading={false}
error={home.error}
retry={home.retry}
/>
)}
{view === 'opportunities' && (
<OpportunitiesView
filters={filters}
setFilters={setFilters}
onView={handleView}
onInterest={handleInterest}
/>
)}
{view === 'detail' && selected && (
<OpportunityDetail
opportunity={selected}
onBack={goToOpportunities}
onInterest={handleInterest}
/>
)}
{view === 'interest' && (
<InterestForm
key={selected?.reference ?? 'general'}
preselected={selected}
countries={countries}
onSubmitted={() => setRequestCount((c) => c + 1)}
/>
)}
</main>
<Footer />
<style>{`
.input {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.input:focus {
outline: none;
box-shadow: 0 0 0 2px #10b981;
}
`}</style>
</div>
</SectorContext.Provider>
);
}