Files
Investidor-V2/resources/js/components/portal-hero.tsx
T

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>
);
}