diff --git a/app/Http/Controllers/GlobalSearchController.php b/app/Http/Controllers/GlobalSearchController.php index ca7e97b5..61535617 100644 --- a/app/Http/Controllers/GlobalSearchController.php +++ b/app/Http/Controllers/GlobalSearchController.php @@ -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'])); diff --git a/resources/js/pages/portal/search.tsx b/resources/js/pages/portal/search.tsx index 935defe7..0fab017e 100644 --- a/resources/js/pages/portal/search.tsx +++ b/resources/js/pages/portal/search.tsx @@ -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 = { + '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 = { + 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 ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} 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 ( +
+ {QUICK_LINKS.map((link) => { + const Icon = link.icon; + + return ( + + + + + + {link.label} + + + {link.description} + + +
+ ); +} + +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 ( +
+ +
+
- return
- -
-

Pesquisa no InvesteMoz

-
- - 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..." /> - -
+ {unavailable.length > 0 && ( +

+ Não foi possível consultar: {unavailable.join(', ')}. Os + restantes resultados continuam disponíveis; tente + novamente mais tarde. +

+ )} + + {query ? ( + <> +
+

+ {total > 0 + ? `Resultados ${start}–${end} de ${total} para “${query}”.` + : `Nenhum resultado nas fontes consultadas para “${query}”.`} +

+ {last > 1 && ( + + Página {current} de {last} + + )} +
+ + {total === 0 ? ( +
+ + +

+ Sem correspondências +

+

+ Experimente outras palavras ou uma expressão + mais curta. +

+
+ +
+
+ ) : ( +
    + {data.map((item) => { + const meta = + CATEGORY_META[item.category] ?? + DEFAULT_META; + const Icon = meta.icon; + + return ( +
  1. +
    + +
    +

    + {item.category} +

    +

    + + + +

    + {item.description && ( +

    + +

    + )} +
    +
    +
  2. + ); + })} +
+ )} + + {last > 1 && ( + + )} + + ) : ( +
+ + +

+ Comece por uma pesquisa +

+

+ Encontre oportunidades, sectores, documentos e + serviços do portal. Os resultados reúnem as fontes + públicas disponíveis. +

+
+ +
+
+ )}
-
-
-

Pesquise por palavras em qualquer ordem. Pode escrever com ou sem acentos. Os documentos são pesquisados pelos seus dados descritivos.

- {unavailable.length > 0 &&

Não foi possível consultar: {unavailable.join(', ')}. Os restantes resultados continuam disponíveis; tente novamente mais tarde.

} - {query &&

{total > 0 ? `Resultados ${start}–${end} de ${total}` : 'Nenhum resultado nas fontes consultadas'} para “{query}”.

} - {query && total === 0 &&

Experimente outras palavras ou uma expressão mais curta.

} -
    - {data.map((item) =>
  1. -

    {item.category}

    - {item.title} - {item.description &&

    {item.description}

    } -
  2. )} -
- {last > 1 && } -
-
; +
+ ); } diff --git a/tests/frontend/global-search.test.mjs b/tests/frontend/global-search.test.mjs index 1820cd39..a374ac3e 100644 --- a/tests/frontend/global-search.test.mjs +++ b/tests/frontend/global-search.test.mjs @@ -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/);