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

1262 lines
54 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 { 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>
);
}