1262 lines
54 KiB
TypeScript
1262 lines
54 KiB
TypeScript
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';
|
||
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 { 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 {
|
||
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[]>([]);
|
||
|
||
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-5 text-slate-600 sm:py-6"
|
||
>
|
||
A carregar dados...
|
||
</p>
|
||
);
|
||
if (!error) return null;
|
||
return (
|
||
<div
|
||
role="alert"
|
||
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
|
||
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 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
|
||
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>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 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.trim() });
|
||
}
|
||
|
||
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' && (
|
||
<>
|
||
<PortalHero
|
||
query={heroQuery}
|
||
setQuery={setHeroQuery}
|
||
onSearch={handleHeroSearch}
|
||
stats={home.data?.data.stats}
|
||
onNavigate={navigate}
|
||
/>
|
||
<InvestmentContextCards />
|
||
<QueryStatus
|
||
loading={home.loading}
|
||
error={home.error}
|
||
retry={home.retry}
|
||
/>
|
||
{home.data && (
|
||
<HomepageSectors
|
||
sectors={sectors.map((sector) => ({
|
||
...sector,
|
||
description: sector.shortDescription,
|
||
}))}
|
||
/>
|
||
)}
|
||
|
||
<InvestmentSteps />
|
||
|
||
<section className={HOME_SECTION_BAND}>
|
||
<div className="mx-auto max-w-6xl px-5">
|
||
<div className="mb-5 flex items-end justify-between">
|
||
<h2 className={HOME_SECTION_HEADING}>
|
||
Oportunidades em destaque
|
||
</h2>
|
||
<button
|
||
onClick={goToOpportunities}
|
||
className="inline-flex items-center gap-1 text-sm font-bold text-[#ed1c24] hover:underline"
|
||
>
|
||
<span>Ver todas</span>
|
||
<ArrowRight
|
||
className="h-4 w-4"
|
||
aria-hidden="true"
|
||
/>
|
||
</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={`${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="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"
|
||
>
|
||
<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>
|
||
{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="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"
|
||
>
|
||
Contactar Apoio ao Investidor
|
||
<ArrowRight
|
||
className="h-4 w-4"
|
||
aria-hidden="true"
|
||
/>
|
||
</a>
|
||
<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>
|
||
</>
|
||
)}
|
||
|
||
{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>
|
||
);
|
||
}
|