Merge branch 'front' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Waiting to run
tests / ci (push) Waiting to run
This commit is contained in:
@@ -29,7 +29,8 @@ class GlobalSearchController extends Controller
|
||||
if ($tokens !== []) {
|
||||
$factories = [
|
||||
'Páginas e serviços' => function () use ($search, $tokens, $term) {
|
||||
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
|
||||
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)
|
||||
&& $search->score($page['title'], $term, $tokens) > 0));
|
||||
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
|
||||
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
|
||||
|
||||
|
||||
@@ -1,54 +1,573 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Factory,
|
||||
FileText,
|
||||
Handshake,
|
||||
Landmark,
|
||||
Layers,
|
||||
MapPin,
|
||||
Newspaper,
|
||||
Search as SearchIcon,
|
||||
Store,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
type Result = { title: string; description: string | null; url: string; category: string };
|
||||
type Results = { data: Result[]; total: number; current_page: number; last_page: number; per_page: number; unavailable: string[] };
|
||||
type Result = {
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
category: string;
|
||||
};
|
||||
type Results = {
|
||||
data: Result[];
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
unavailable: string[];
|
||||
};
|
||||
|
||||
type CategoryMeta = { icon: LucideIcon; iconClass: string; badgeClass: string };
|
||||
|
||||
const CATEGORY_META: Record<string, CategoryMeta> = {
|
||||
'Páginas e serviços': {
|
||||
icon: Compass,
|
||||
iconClass: 'bg-slate-100 text-slate-700',
|
||||
badgeClass: 'bg-slate-100 text-slate-700',
|
||||
},
|
||||
Oportunidades: {
|
||||
icon: TrendingUp,
|
||||
iconClass: 'bg-red-50 text-[#b5121b]',
|
||||
badgeClass: 'bg-red-50 text-[#b5121b]',
|
||||
},
|
||||
Negócios: {
|
||||
icon: Store,
|
||||
iconClass: 'bg-amber-50 text-amber-700',
|
||||
badgeClass: 'bg-amber-50 text-amber-700',
|
||||
},
|
||||
'Carteira: projectos, clusters e corredores': {
|
||||
icon: Layers,
|
||||
iconClass: 'bg-indigo-50 text-indigo-700',
|
||||
badgeClass: 'bg-indigo-50 text-indigo-700',
|
||||
},
|
||||
'Documentos e legislação': {
|
||||
icon: FileText,
|
||||
iconClass: 'bg-blue-50 text-blue-700',
|
||||
badgeClass: 'bg-blue-50 text-blue-700',
|
||||
},
|
||||
Artigos: {
|
||||
icon: Newspaper,
|
||||
iconClass: 'bg-violet-50 text-violet-700',
|
||||
badgeClass: 'bg-violet-50 text-violet-700',
|
||||
},
|
||||
Sectores: {
|
||||
icon: Factory,
|
||||
iconClass: 'bg-sky-50 text-sky-700',
|
||||
badgeClass: 'bg-sky-50 text-sky-700',
|
||||
},
|
||||
Localizações: {
|
||||
icon: MapPin,
|
||||
iconClass: 'bg-teal-50 text-teal-700',
|
||||
badgeClass: 'bg-teal-50 text-teal-700',
|
||||
},
|
||||
'Zonas de investimento': {
|
||||
icon: Landmark,
|
||||
iconClass: 'bg-cyan-50 text-cyan-700',
|
||||
badgeClass: 'bg-cyan-50 text-cyan-700',
|
||||
},
|
||||
Parcerias: {
|
||||
icon: Handshake,
|
||||
iconClass: 'bg-orange-50 text-orange-700',
|
||||
badgeClass: 'bg-orange-50 text-orange-700',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_META: CategoryMeta = {
|
||||
icon: Compass,
|
||||
iconClass: 'bg-slate-100 text-slate-700',
|
||||
badgeClass: 'bg-slate-100 text-slate-700',
|
||||
};
|
||||
|
||||
const VARIANT_GROUPS = [
|
||||
['sector', 'sectores', 'setor', 'setores'],
|
||||
['projecto', 'projectos', 'projeto', 'projetos'],
|
||||
['investir', 'investimento', 'investimentos'],
|
||||
['investidor', 'investidores'],
|
||||
['oportunidade', 'oportunidades'],
|
||||
['documento', 'documentos'],
|
||||
['empresa', 'empresas'],
|
||||
['parceria', 'parcerias'],
|
||||
['zona', 'zonas'],
|
||||
['formulario', 'formularios'],
|
||||
['provincia', 'provincias'],
|
||||
['corredor', 'corredores'],
|
||||
['cluster', 'clusters'],
|
||||
['imposto', 'impostos'],
|
||||
['licenca', 'licencas'],
|
||||
];
|
||||
|
||||
const ACCENT_CLASSES: Record<string, string> = {
|
||||
a: '[aáàâãä]',
|
||||
e: '[eéèêë]',
|
||||
i: '[iíìîï]',
|
||||
o: '[oóòôõö]',
|
||||
u: '[uúùûü]',
|
||||
c: '[cç]',
|
||||
n: '[nñ]',
|
||||
};
|
||||
|
||||
const QUICK_LINKS = [
|
||||
{
|
||||
label: 'Sectores de investimento',
|
||||
description: 'Áreas prioritárias',
|
||||
href: '/sectores',
|
||||
icon: Factory,
|
||||
},
|
||||
{
|
||||
label: 'Oportunidades',
|
||||
description: 'Projectos por sector e localização',
|
||||
href: '/oportunidades',
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: 'Documentos',
|
||||
description: 'Acervo e legislação',
|
||||
href: '/documentos',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: 'Zonas de investimento',
|
||||
description: 'ZEE, ZFI e parques',
|
||||
href: '/zonas',
|
||||
icon: Landmark,
|
||||
},
|
||||
{
|
||||
label: 'Parcerias',
|
||||
description: 'Parceiros e propostas',
|
||||
href: '/parcerias',
|
||||
icon: Handshake,
|
||||
},
|
||||
];
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function baseLetters(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function accentInsensitivePattern(token: string): string {
|
||||
return baseLetters(token)
|
||||
.split('')
|
||||
.map((letter) => ACCENT_CLASSES[letter] ?? escapeRegExp(letter))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function tokenVariants(token: string): string[] {
|
||||
const group = VARIANT_GROUPS.find((variants) =>
|
||||
variants.includes(baseLetters(token)),
|
||||
);
|
||||
|
||||
return group ?? [token];
|
||||
}
|
||||
|
||||
function buildHighlightRegex(term: string): RegExp | null {
|
||||
const tokens = [
|
||||
...new Set(
|
||||
term
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.map((token) => token.replace(/[.,;:!?"'()[\]{}]/g, ''))
|
||||
.filter((token) => token.length > 1),
|
||||
),
|
||||
];
|
||||
if (!tokens.length) return null;
|
||||
|
||||
const source = tokens
|
||||
.map((token) => {
|
||||
const variants = tokenVariants(token).map(accentInsensitivePattern);
|
||||
|
||||
return variants.length > 1
|
||||
? `(?:${variants.join('|')})`
|
||||
: variants[0];
|
||||
})
|
||||
.join('|');
|
||||
|
||||
try {
|
||||
return new RegExp(`(${source})`, 'gi');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function Highlight({
|
||||
text,
|
||||
regex,
|
||||
}: {
|
||||
text: string | null;
|
||||
regex: RegExp | null;
|
||||
}) {
|
||||
if (!text) return null;
|
||||
if (!regex) return <>{text}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{text.split(regex).map((part, index) =>
|
||||
index % 2 === 1 ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="rounded-sm bg-[#ed1c24]/10 px-0.5 text-inherit"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pageNumbers(current: number, last: number): (number | string)[] {
|
||||
const pages = [...new Set([1, last, ...Array.from({ length: 5 }, (_, i) => current - 2 + i)])]
|
||||
.filter((page) => page >= 1 && page <= last).sort((a, b) => a - b);
|
||||
return pages.flatMap((page, i) => i > 0 && page - pages[i - 1] > 1 ? [`gap-${page}`, page] : [page]);
|
||||
const pages = [
|
||||
...new Set([
|
||||
1,
|
||||
last,
|
||||
...Array.from({ length: 5 }, (_, i) => current - 2 + i),
|
||||
]),
|
||||
]
|
||||
.filter((page) => page >= 1 && page <= last)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
return pages.flatMap((page, i) =>
|
||||
i > 0 && page - pages[i - 1] > 1 ? [`gap-${page}`, page] : [page],
|
||||
);
|
||||
}
|
||||
|
||||
export default function Search({ query, results }: { query: string; results: Results }) {
|
||||
function QuickLinks() {
|
||||
return (
|
||||
<div className="grid gap-2 text-left sm:grid-cols-2 lg:grid-cols-3">
|
||||
{QUICK_LINKS.map((link) => {
|
||||
const Icon = link.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-3 transition-colors hover:border-[#ed1c24]/40 hover:bg-red-50/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-700 transition-colors group-hover:bg-[#ed1c24] group-hover:text-white">
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-[#101820]">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-slate-500">
|
||||
{link.description}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-slate-300 transition-colors group-hover:text-[#ed1c24]"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Search({
|
||||
query,
|
||||
results,
|
||||
}: {
|
||||
query: string;
|
||||
results: Results;
|
||||
}) {
|
||||
const [input, setInput] = useState(query);
|
||||
useEffect(() => setInput(query), [query]);
|
||||
const { data, total, current_page: current, last_page: last, per_page: perPage, unavailable } = results;
|
||||
const pageUrl = (page: number) => `/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
|
||||
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
current_page: current,
|
||||
last_page: last,
|
||||
per_page: perPage,
|
||||
unavailable,
|
||||
} = results;
|
||||
const pageUrl = (page: number) =>
|
||||
`/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
|
||||
const start = total ? (current - 1) * perPage + 1 : 0;
|
||||
const end = Math.min(start + data.length - 1, total);
|
||||
const highlight = useMemo(() => buildHighlightRegex(query), [query]);
|
||||
|
||||
return (
|
||||
<main className="min-h-[65vh] bg-slate-50 text-[#101820]">
|
||||
<Head title="Pesquisa" />
|
||||
<section className="relative overflow-hidden bg-[#101820] text-white">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
<div className="absolute -top-32 right-0 size-80 rounded-full bg-[#ed1c24]/25 blur-3xl" />
|
||||
<div className="absolute -bottom-40 left-1/4 size-80 rounded-full bg-sky-500/20 blur-3xl" />
|
||||
</div>
|
||||
<div className="relative mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<p className="text-xs font-bold tracking-[0.2em] text-white/60 uppercase">
|
||||
Pesquisa global
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Pesquisa no InvesteMoz
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-white/70">
|
||||
Oportunidades, sectores, documentos, zonas e serviços
|
||||
oficiais num só lugar.
|
||||
</p>
|
||||
<form
|
||||
action="/pesquisa"
|
||||
role="search"
|
||||
className="mt-6 flex max-w-3xl flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<label htmlFor="search-query" className="sr-only">
|
||||
Pesquisar no InvesteMoz
|
||||
</label>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<SearchIcon
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<input
|
||||
id="search-query"
|
||||
type="search"
|
||||
name="q"
|
||||
value={input}
|
||||
onChange={(event) =>
|
||||
setInput(event.target.value)
|
||||
}
|
||||
maxLength={120}
|
||||
className="min-h-12 w-full rounded-xl border border-white/10 bg-white pr-4 pl-11 text-base text-[#101820] shadow-lg shadow-black/20 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-[#ed1c24]"
|
||||
placeholder="Oportunidades, documentos, sectores…"
|
||||
/>
|
||||
</div>
|
||||
<button className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-[#ed1c24] px-6 font-semibold text-white shadow-lg shadow-[#ed1c24]/25 transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">
|
||||
<SearchIcon size={18} aria-hidden="true" />
|
||||
Pesquisar
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-3 max-w-3xl text-xs leading-relaxed text-white/55">
|
||||
Pesquise por palavras em qualquer ordem. Pode escrever
|
||||
com ou sem acentos. Os documentos são pesquisados pelos
|
||||
seus dados descritivos.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
return <main className="min-h-[65vh] bg-white text-[#101820]">
|
||||
<Head title="Pesquisa" />
|
||||
<div className="border-b border-slate-200 bg-[#101820] text-white">
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Pesquisa no InvesteMoz</h1>
|
||||
<form action="/pesquisa" role="search" className="mt-5 flex max-w-3xl flex-col gap-2 sm:flex-row">
|
||||
<label htmlFor="search-query" className="sr-only">Pesquisar no InvesteMoz</label>
|
||||
<input id="search-query" type="search" name="q" value={input} onChange={(event) => setInput(event.target.value)} maxLength={120} className="min-h-12 min-w-0 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-base text-[#101820] outline-none focus:ring-2 focus:ring-[#ed1c24]" placeholder="Oportunidades, documentos, sectores..." />
|
||||
<button className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-[#ed1c24] px-6 font-semibold text-white hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><SearchIcon size={18} aria-hidden="true" />Pesquisar</button>
|
||||
</form>
|
||||
{unavailable.length > 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900"
|
||||
>
|
||||
Não foi possível consultar: {unavailable.join(', ')}. Os
|
||||
restantes resultados continuam disponíveis; tente
|
||||
novamente mais tarde.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{query ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="text-sm text-slate-600"
|
||||
>
|
||||
{total > 0
|
||||
? `Resultados ${start}–${end} de ${total} para “${query}”.`
|
||||
: `Nenhum resultado nas fontes consultadas para “${query}”.`}
|
||||
</p>
|
||||
{last > 1 && (
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||||
Página {current} de {last}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<div className="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-slate-100 text-slate-500">
|
||||
<SearchIcon size={22} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="mt-4 text-lg font-semibold">
|
||||
Sem correspondências
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Experimente outras palavras ou uma expressão
|
||||
mais curta.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<QuickLinks />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ol start={start || 1} className="mt-5 space-y-3">
|
||||
{data.map((item) => {
|
||||
const meta =
|
||||
CATEGORY_META[item.category] ??
|
||||
DEFAULT_META;
|
||||
const Icon = meta.icon;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${item.category}:${item.url}`}
|
||||
>
|
||||
<article className="group relative flex gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-[#ed1c24]/40 hover:shadow-lg motion-reduce:transition-none sm:p-5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex size-11 shrink-0 items-center justify-center rounded-xl ${meta.iconClass}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold tracking-wider uppercase ${meta.badgeClass}`}
|
||||
>
|
||||
{item.category}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg leading-snug font-bold">
|
||||
<Link
|
||||
href={item.url}
|
||||
className="text-[#101820] underline-offset-4 transition-colors group-hover:text-[#b5121b] after:absolute after:inset-0 focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
<Highlight
|
||||
text={
|
||||
item.title
|
||||
}
|
||||
regex={
|
||||
highlight
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</h2>
|
||||
{item.description && (
|
||||
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-slate-600">
|
||||
<Highlight
|
||||
text={
|
||||
item.description
|
||||
}
|
||||
regex={
|
||||
highlight
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
size={20}
|
||||
aria-hidden="true"
|
||||
className="mt-1 size-5 shrink-0 text-slate-300 transition-colors group-hover:text-[#ed1c24]"
|
||||
/>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{last > 1 && (
|
||||
<nav
|
||||
className="mt-8 flex flex-wrap items-center justify-center gap-1.5"
|
||||
aria-label="Páginas de resultados"
|
||||
>
|
||||
{current > 1 && (
|
||||
<Link
|
||||
href={pageUrl(current - 1)}
|
||||
rel="prev"
|
||||
className="inline-flex min-h-10 items-center gap-1 rounded-lg px-3 text-sm font-semibold text-[#b5121b] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
<ChevronLeft
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{pageNumbers(current, last).map((page) =>
|
||||
typeof page === 'string' ? (
|
||||
<span
|
||||
key={page}
|
||||
className="px-1 text-slate-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={page}
|
||||
href={pageUrl(page)}
|
||||
aria-label={`Página ${page}`}
|
||||
aria-current={
|
||||
page === current
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
page === current
|
||||
? 'inline-flex min-h-10 min-w-10 items-center justify-center rounded-lg bg-[#101820] px-3 text-sm font-bold text-white'
|
||||
: 'inline-flex min-h-10 min-w-10 items-center justify-center rounded-lg px-3 text-sm font-semibold text-[#b5121b] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]'
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
{current < last && (
|
||||
<Link
|
||||
href={pageUrl(current + 1)}
|
||||
rel="next"
|
||||
className="inline-flex min-h-10 items-center gap-1 rounded-lg px-3 text-sm font-semibold text-[#b5121b] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
|
||||
>
|
||||
Seguinte
|
||||
<ChevronRight
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center sm:p-10">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-100 text-slate-500">
|
||||
<SearchIcon size={24} aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="mt-4 text-xl font-bold">
|
||||
Comece por uma pesquisa
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-600">
|
||||
Encontre oportunidades, sectores, documentos e
|
||||
serviços do portal. Os resultados reúnem as fontes
|
||||
públicas disponíveis.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<QuickLinks />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl px-4 py-7 sm:px-6">
|
||||
<p className="max-w-3xl text-sm leading-relaxed text-slate-600">Pesquise por palavras em qualquer ordem. Pode escrever com ou sem acentos. Os documentos são pesquisados pelos seus dados descritivos.</p>
|
||||
{unavailable.length > 0 && <p role="alert" className="mt-4 rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm">Não foi possível consultar: {unavailable.join(', ')}. Os restantes resultados continuam disponíveis; tente novamente mais tarde.</p>}
|
||||
{query && <p className="mt-6 text-sm text-slate-600" aria-live="polite">{total > 0 ? `Resultados ${start}–${end} de ${total}` : 'Nenhum resultado nas fontes consultadas'} para “{query}”.</p>}
|
||||
{query && total === 0 && <p className="mt-4 text-slate-600">Experimente outras palavras ou uma expressão mais curta.</p>}
|
||||
<ol start={start || 1} className="mt-2 max-w-3xl divide-y divide-slate-200">
|
||||
{data.map((item) => <li key={`${item.category}:${item.url}`} className="py-5">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{item.category}</p>
|
||||
<Link href={item.url} className="text-lg font-semibold text-[#b5121b] underline-offset-4 hover:underline focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]">{item.title}</Link>
|
||||
{item.description && <p className="mt-1 line-clamp-3 text-sm leading-relaxed text-slate-600">{item.description}</p>}
|
||||
</li>)}
|
||||
</ol>
|
||||
{last > 1 && <nav className="mt-8 flex flex-wrap items-center gap-2" aria-label="Páginas de resultados">
|
||||
{current > 1 && <Link href={pageUrl(current - 1)} rel="prev" className="rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50">Anterior</Link>}
|
||||
{pageNumbers(current, last).map((page) => typeof page === 'string' ? <span key={page} className="px-1 text-slate-500" aria-hidden="true">…</span> :
|
||||
<Link key={page} href={pageUrl(page)} aria-label={`Página ${page}`} aria-current={page === current ? 'page' : undefined} className={page === current ? 'rounded-md bg-[#101820] px-3 py-2 text-sm font-bold text-white' : 'rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50'}>{page}</Link>)}
|
||||
{current < last && <Link href={pageUrl(current + 1)} rel="next" className="rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50">Seguinte</Link>}
|
||||
</nav>}
|
||||
</div>
|
||||
</main>;
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,18 +7,53 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import ts from 'typescript';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const source = fs.readFileSync(new URL('../../resources/js/pages/portal/search.tsx', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../resources/js/pages/portal/search.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(name => name === '@inertiajs/react' ? {
|
||||
Head: () => null,
|
||||
Link: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children),
|
||||
} : name === 'lucide-react' ? { Search: () => null } : require(name), module, module.exports);
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) =>
|
||||
name === '@inertiajs/react'
|
||||
? {
|
||||
Head: () => null,
|
||||
Link: ({ href, children, ...props }) =>
|
||||
React.createElement('a', { href, ...props }, children),
|
||||
}
|
||||
: name === 'lucide-react'
|
||||
? new Proxy({}, { get: () => () => null })
|
||||
: require(name),
|
||||
module,
|
||||
module.exports,
|
||||
);
|
||||
const Search = module.exports.default;
|
||||
|
||||
test('shows five results in one sequence with numbered pagination and the query preserved', () => {
|
||||
const results = { data: [{ title: 'Energia em Nampula', description: 'Projecto público', url: '/oportunidades/energia', category: 'Oportunidades' }], total: 12, current_page: 1, last_page: 3, per_page: 5, unavailable: [] };
|
||||
const html = renderToStaticMarkup(React.createElement(Search, { query: 'energia', results }));
|
||||
const results = {
|
||||
data: [
|
||||
{
|
||||
title: 'Energia em Nampula',
|
||||
description: 'Projecto público',
|
||||
url: '/oportunidades/energia',
|
||||
category: 'Oportunidades',
|
||||
},
|
||||
],
|
||||
total: 12,
|
||||
current_page: 1,
|
||||
last_page: 3,
|
||||
per_page: 5,
|
||||
unavailable: [],
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(Search, { query: 'energia', results }),
|
||||
);
|
||||
assert.match(html, /Oportunidades/);
|
||||
assert.match(html, /Resultados 1–1 de 12/);
|
||||
assert.match(html, /href="\/oportunidades\/energia"/);
|
||||
@@ -28,8 +63,17 @@ test('shows five results in one sequence with numbered pagination and the query
|
||||
});
|
||||
|
||||
test('makes source failures explicit without hiding other results', () => {
|
||||
const results = { data: [], total: 0, current_page: 1, last_page: 1, per_page: 5, unavailable: ['Documentos e legislação'] };
|
||||
const html = renderToStaticMarkup(React.createElement(Search, { query: 'terra', results }));
|
||||
const results = {
|
||||
data: [],
|
||||
total: 0,
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 5,
|
||||
unavailable: ['Documentos e legislação'],
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(Search, { query: 'terra', results }),
|
||||
);
|
||||
assert.match(html, /role="alert"/);
|
||||
assert.match(html, /Não foi possível consultar: Documentos e legislação/);
|
||||
assert.match(html, /Nenhum resultado nas fontes consultadas/);
|
||||
|
||||
Reference in New Issue
Block a user