346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
import { Link, router } from '@inertiajs/react';
|
|
import { ArrowRight, Search } from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
import type { HomeResponse } from '@/lib/portal-api';
|
|
|
|
const slides = [
|
|
{
|
|
image: '/images/hero/hero-1.webp',
|
|
mobile: '/images/hero/hero-1-mobile.webp',
|
|
},
|
|
{
|
|
image: '/images/hero/hero-3.webp',
|
|
mobile: '/images/hero/hero-3-mobile.webp',
|
|
},
|
|
];
|
|
|
|
const heroTitle = 'INVISTA EM MOÇAMBIQUE';
|
|
const heroSubtitle =
|
|
'Transforme informação em decisões e ideias em negócios reais.';
|
|
const typingStart = 250;
|
|
const titleStep = 50;
|
|
const subtitleStep = 28;
|
|
const subtitleStart = typingStart + heroTitle.length * titleStep + 150;
|
|
const typingDuration = subtitleStart + heroSubtitle.length * subtitleStep;
|
|
|
|
function TypewriterLine({
|
|
text,
|
|
visibleText,
|
|
caret = false,
|
|
running,
|
|
}: {
|
|
text: string;
|
|
visibleText: string;
|
|
caret?: boolean;
|
|
running: boolean;
|
|
}) {
|
|
return (
|
|
<span className="grid">
|
|
<span className="sr-only">{text}</span>
|
|
{/* Reserve the final wrapping before the first character appears. */}
|
|
<span
|
|
aria-hidden="true"
|
|
className="invisible col-start-1 row-start-1"
|
|
>
|
|
{text}
|
|
{caret && <span className="ml-1 inline-block w-0 border-l-2" />}
|
|
</span>
|
|
<span
|
|
aria-hidden="true"
|
|
data-hero-typed
|
|
className="col-start-1 row-start-1"
|
|
>
|
|
{visibleText}
|
|
{caret && (
|
|
<span
|
|
className="portal-hero-caret ml-1 inline-block h-[0.85em] w-0 border-l-2 border-white"
|
|
style={{
|
|
animationPlayState: running ? 'running' : 'paused',
|
|
}}
|
|
/>
|
|
)}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function HeroAnimatedCopy({
|
|
reducedMotion,
|
|
running,
|
|
}: {
|
|
reducedMotion: boolean;
|
|
running: boolean;
|
|
}) {
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const complete = elapsed >= typingDuration;
|
|
|
|
useEffect(() => {
|
|
if (reducedMotion || !running || complete) return;
|
|
const timer = window.setInterval(() => {
|
|
setElapsed((current) => Math.min(current + 25, typingDuration));
|
|
}, 25);
|
|
return () => window.clearInterval(timer);
|
|
}, [reducedMotion, running, complete]);
|
|
|
|
const titleLength = Math.max(
|
|
0,
|
|
Math.floor((elapsed - typingStart) / titleStep),
|
|
);
|
|
const subtitleLength = Math.max(
|
|
0,
|
|
Math.floor((elapsed - subtitleStart) / subtitleStep),
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<h1
|
|
id="portal-hero-title"
|
|
className="max-w-5xl text-3xl leading-tight font-semibold tracking-tight text-white sm:text-5xl lg:text-6xl"
|
|
>
|
|
<TypewriterLine
|
|
text={heroTitle}
|
|
visibleText={
|
|
reducedMotion
|
|
? heroTitle
|
|
: heroTitle.slice(0, titleLength)
|
|
}
|
|
caret={!reducedMotion}
|
|
running={running}
|
|
/>
|
|
</h1>
|
|
<p className="mt-5 max-w-3xl text-lg leading-relaxed font-medium text-white sm:text-2xl">
|
|
<TypewriterLine
|
|
text={heroSubtitle}
|
|
visibleText={
|
|
reducedMotion
|
|
? heroSubtitle
|
|
: heroSubtitle.slice(0, subtitleLength)
|
|
}
|
|
running={running}
|
|
/>
|
|
</p>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type PortalHeroProps = {
|
|
query: string;
|
|
setQuery: (value: string) => void;
|
|
stats?: HomeResponse['data']['stats'];
|
|
};
|
|
|
|
/** Reference visuals adapted to the V2 data and navigation contracts. */
|
|
export default function PortalHero({
|
|
query,
|
|
setQuery,
|
|
stats,
|
|
}: PortalHeroProps) {
|
|
const [currentSlide, setCurrentSlide] = useState(0);
|
|
const [interacting, setInteracting] = useState(false);
|
|
const [hovering, setHovering] = useState(false);
|
|
const [pageVisible, setPageVisible] = useState(true);
|
|
// Start static for SSR and until the browser's motion preference is known.
|
|
const [reducedMotion, setReducedMotion] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const preference = window.matchMedia(
|
|
'(prefers-reduced-motion: reduce)',
|
|
);
|
|
const updateMotion = () => setReducedMotion(preference.matches);
|
|
const updateVisibility = () => setPageVisible(!document.hidden);
|
|
updateMotion();
|
|
updateVisibility();
|
|
preference.addEventListener('change', updateMotion);
|
|
document.addEventListener('visibilitychange', updateVisibility);
|
|
return () => {
|
|
preference.removeEventListener('change', updateMotion);
|
|
document.removeEventListener('visibilitychange', updateVisibility);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (reducedMotion || interacting || hovering || !pageVisible) return;
|
|
const timer = window.setInterval(() => {
|
|
setCurrentSlide((current) => (current + 1) % slides.length);
|
|
}, 8000);
|
|
return () => window.clearInterval(timer);
|
|
}, [reducedMotion, interacting, hovering, pageVisible]);
|
|
|
|
return (
|
|
<section
|
|
aria-labelledby="portal-hero-title"
|
|
className="portal-hero relative isolate overflow-hidden bg-[#101820] text-white"
|
|
onMouseEnter={() => setHovering(true)}
|
|
onMouseLeave={() => setHovering(false)}
|
|
onFocusCapture={() => setInteracting(true)}
|
|
onBlurCapture={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget))
|
|
setInteracting(false);
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 -z-20" aria-hidden="true">
|
|
{slides.map((slide, index) => (
|
|
<picture key={slide.image}>
|
|
<source
|
|
media="(max-width: 640px)"
|
|
srcSet={slide.mobile}
|
|
/>
|
|
<img
|
|
src={slide.image}
|
|
alt=""
|
|
width={1920}
|
|
height={1280}
|
|
fetchPriority={index === 0 ? 'high' : 'low'}
|
|
decoding="async"
|
|
className={`portal-hero-image absolute inset-0 h-full w-full object-cover transition-opacity duration-1500 motion-reduce:transition-none ${currentSlide === index ? 'opacity-100' : 'opacity-0'}`}
|
|
/>
|
|
</picture>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="absolute inset-0 -z-10 bg-linear-to-r from-black/75 via-black/65 to-black/60"
|
|
aria-hidden="true"
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 bottom-0 -z-10 h-48 bg-linear-to-t from-black/65 to-transparent"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<div className="mx-auto flex min-h-[88svh] max-w-6xl flex-col items-center justify-center px-5 py-12 text-center sm:px-6 sm:py-16 lg:py-20">
|
|
<HeroAnimatedCopy
|
|
key={currentSlide}
|
|
reducedMotion={reducedMotion}
|
|
running={pageVisible}
|
|
/>
|
|
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-white/90 sm:text-base">
|
|
Oportunidades reais de investimento em Moçambique,
|
|
organizadas por sector.
|
|
</p>
|
|
<p
|
|
id="portal-hero-description"
|
|
className="mt-2 max-w-2xl text-sm leading-relaxed text-white/80 sm:text-base"
|
|
>
|
|
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>
|
|
|
|
<form
|
|
action="/pesquisa"
|
|
method="get"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const value = new FormData(event.currentTarget).get(
|
|
'q',
|
|
);
|
|
router.get('/pesquisa', {
|
|
q:
|
|
typeof value === 'string'
|
|
? value.trim()
|
|
: query.trim(),
|
|
});
|
|
}}
|
|
role="search"
|
|
aria-label="Pesquisar no InvesteMoz"
|
|
className="mt-7 w-full max-w-4xl"
|
|
>
|
|
<label
|
|
htmlFor="portal-opportunity-search"
|
|
className="mb-2 block text-left text-sm font-medium text-white/90"
|
|
>
|
|
Pesquisa rápida no InvesteMoz
|
|
</label>
|
|
<div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-white/30 bg-white p-1.5 shadow-2xl shadow-black/20 focus-within:ring-2 focus-within:ring-white focus-within:ring-offset-4 focus-within:ring-offset-[#101820] min-[400px]:flex-row">
|
|
<input
|
|
id="portal-opportunity-search"
|
|
type="search"
|
|
name="q"
|
|
maxLength={120}
|
|
aria-describedby="portal-opportunity-search-help"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Ex.: agronegócio, Nampula, legislação..."
|
|
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="inline-flex min-h-12 shrink-0 items-center justify-center gap-2 rounded-xl bg-[#ed1c24] px-5 text-sm font-semibold text-white transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
|
>
|
|
<Search size={18} aria-hidden="true" />
|
|
Pesquisar
|
|
</button>
|
|
</div>
|
|
<p
|
|
id="portal-opportunity-search-help"
|
|
className="mt-2 text-left text-sm text-white/90"
|
|
>
|
|
Combine palavras-chave, sector e localização. Ex.:
|
|
plantio de algodão ou agronegócio Nampula.
|
|
</p>
|
|
</form>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
|
<Link
|
|
href="/porque-investir"
|
|
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
|
>
|
|
Porquê investir em Moçambique?
|
|
</Link>
|
|
<Link
|
|
href="/investimentos/iniciar"
|
|
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
|
>
|
|
Partir de uma ideia
|
|
</Link>
|
|
<Link
|
|
href="/oportunidades"
|
|
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
|
>
|
|
Consultar oportunidades
|
|
<ArrowRight size={16} aria-hidden="true" />
|
|
</Link>
|
|
<Link
|
|
href="/simulador-fiscal"
|
|
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
|
>
|
|
Simulador Fiscal
|
|
</Link>
|
|
<Link
|
|
href="/sectores"
|
|
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
|
>
|
|
Sectores de investimento
|
|
</Link>
|
|
</div>
|
|
|
|
<dl
|
|
aria-label="Indicadores do portal"
|
|
className="mt-9 grid w-full max-w-lg grid-cols-3 gap-3 border-t border-white/25 pt-5"
|
|
>
|
|
{[
|
|
['oportunidades', stats?.opportunities],
|
|
['sectores', stats?.sectors],
|
|
['províncias', stats?.provinces],
|
|
].map(([label, value]) => (
|
|
<div
|
|
key={label}
|
|
className="flex flex-col-reverse gap-1"
|
|
>
|
|
<dt className="text-xs text-white/85 sm:text-sm">
|
|
{label}
|
|
</dt>
|
|
<dd className="text-2xl font-semibold text-white tabular-nums sm:text-3xl">
|
|
{value ?? '—'}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<p className="mt-3 max-w-lg text-xs text-white/80">
|
|
Províncias com localização declarada nas oportunidades
|
|
públicas. Cada oportunidade conta uma vez no total.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|