Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/common/app-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const APP_ROUTES = {
home: "/",
contact: "/contacto",
search: "/busqueda",
reportsSearch: "/reportes",
report: "/reportar",
terms: "/terminos-y-condiciones",
scammer: "/estafadores/:id",
Expand Down
4 changes: 4 additions & 0 deletions src/presentation/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { APP_ROUTES } from "@/common/app-routes";
const Home = lazy(() => import("@presentation/pages/home/Home"));
const Contact = lazy(() => import("@presentation/pages/contact/Contact"));
const Search = lazy(() => import("@presentation/pages/search/Search"));
const ReportsSearch = lazy(
() => import("@presentation/pages/reports-search/ReportsSearch"),
);
const NotFound = lazy(() => import("@presentation/pages/404/NotFound"));
const Report = lazy(() => import("@presentation/pages/report/Report"));
const ReportForm = lazy(() => import("@presentation/pages/report-form/ReportForm"));
Expand All @@ -31,6 +34,7 @@ function App() {
<Route path={APP_ROUTES.home} element={<Home />} />
<Route path={APP_ROUTES.contact} element={<Contact />} />
<Route path={APP_ROUTES.search} element={<Search />} />
<Route path={APP_ROUTES.reportsSearch} element={<ReportsSearch />} />
<Route path={APP_ROUTES.report} element={<ReportForm />} />
<Route path={APP_ROUTES.terms} element={<TermsOfUse />} />
<Route
Expand Down
189 changes: 189 additions & 0 deletions src/presentation/pages/reports-search/ReportsSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { reportDetailPath } from "@/common/app-routes";
import Footer from "@presentation/shared/components/Footer";
import Header from "@presentation/shared/components/Header";
import SearchContainer from "@presentation/pages/search/components/SearchContainer";
import NotFound from "@/presentation/pages/search/components/NotFound";
import Formatter from "@/presentation/shared/utils/formatter";
import { getValidPage } from "@/presentation/shared/utils/search-pagination.util";
import PaginationNav from "@/presentation/shared/components/PaginationNav";
import ReportTextSearchForm from "@/presentation/pages/reports-search/components/ReportTextSearchForm";
import { MOCK_TEXT_REPORT_HITS } from "@/presentation/pages/reports-search/mock-reports";

/** When the user has submitted a real query, paginate one hit per page (like /busqueda demos). */
const SEARCH_PAGE_SIZE = 1;
/** Sample / browse mode shows every mock card on one page. */
const BROWSE_PAGE_SIZE = MOCK_TEXT_REPORT_HITS.length;

function ReportsSearch() {
const [searchParams, setSearchParams] = useSearchParams();
const urlQuery = searchParams.get("q") || "";
const formattedUrlQuery = Formatter.FormatInput(urlQuery);
const [query, setQuery] = useState(formattedUrlQuery);
const [syncedUrlQuery, setSyncedUrlQuery] = useState(urlQuery);

if (urlQuery !== syncedUrlQuery) {
setSyncedUrlQuery(urlQuery);
setQuery(formattedUrlQuery);
}

const requestedPage = getValidPage(
searchParams.get("p") || searchParams.get("page"),
);
const hasSubmittedSearch = urlQuery.trim().length > 0;
const normalizedQuery = Formatter.toSearchQuery(formattedUrlQuery);
const filterActive = hasSubmittedSearch && normalizedQuery.length > 0;

const matches = useMemo(() => {
if (!filterActive) {
return MOCK_TEXT_REPORT_HITS;
}

const q = normalizedQuery.toLowerCase();
return MOCK_TEXT_REPORT_HITS.filter((hit) => {
const haystack = `${hit.title} ${hit.snippet}`.toLowerCase();
return haystack.includes(q);
});
}, [filterActive, normalizedQuery]);

const pageSize = filterActive ? SEARCH_PAGE_SIZE : BROWSE_PAGE_SIZE;
const totalResults = matches.length;
const totalPages =
pageSize > 0 ? Math.ceil(totalResults / pageSize) : 0;
const currentPage =
totalPages > 0 ? Math.min(requestedPage, totalPages) : 1;

useEffect(() => {
if (
filterActive &&
normalizedQuery &&
totalPages > 0 &&
requestedPage !== currentPage
) {
setSearchParams(
`?${Formatter.buildSearchQueryString(urlQuery, currentPage)}`,
{ replace: true },
);
}
}, [
currentPage,
filterActive,
normalizedQuery,
requestedPage,
setSearchParams,
totalPages,
urlQuery,
]);
const pageOffset = (currentPage - 1) * pageSize;
const pageResults = matches.slice(pageOffset, pageOffset + pageSize);

const handleInputChange = (event: React.InputEvent<HTMLInputElement>) => {
setQuery(Formatter.FormatInput(event.currentTarget.value));
};

const handleSubmit = () => {
const formatted = Formatter.FormatInput(query);
const nextSearch = formatted.trim()
? `?${Formatter.buildSearchQueryString(formatted, 1)}`
: "";

setSearchParams(nextSearch);
};

const handlePageChange = (page: number) => {
if (page < 1 || page > totalPages || page === currentPage) {
return;
}

setSearchParams(`?${Formatter.buildSearchQueryString(urlQuery, page)}`);
};

return (
<>
<title>FraudeBot - Búsqueda de reportes</title>

<Header />

<SearchContainer>
{/* mt-28 clears the fixed header (h-20); matches /busqueda form offset */}
<h1 className="mb-8 mt-28 px-4 text-center font-[Nunito] text-3xl font-extrabold text-gray-900 sm:text-4xl">
Búsqueda de reportes
</h1>

<ReportTextSearchForm
query={query}
onInputChange={handleInputChange}
onSubmit={handleSubmit}
/>

{!filterActive && (
<p className="mb-6 max-w-3xl px-4 text-center text-sm font-[Nunito] text-gray-500">
Ejemplos de resultados. Usa el buscador para filtrar por palabras
clave.
</p>
)}

{pageResults.length > 0 &&
pageResults.map((hit) => (
<Link
key={hit.id}
to={reportDetailPath(hit.id)}
className="mb-4 flex w-full max-w-3xl flex-col gap-2 border border-gray-200 bg-white p-5 font-[Nunito] shadow-sm transition-all duration-200 ease-in-out hover:border-gray-300 hover:bg-gray-50/50 hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
<div className="flex items-start justify-between gap-3">
<h2 className="min-w-0 flex-1 text-lg font-bold leading-snug text-gray-900">
#{hit.displayId} - {hit.title}
</h2>
<span
className="mt-0.5 shrink-0 text-gray-400"
aria-hidden
>
{/* Diagonal arrow only — avoids a square outline that can read like a checkbox */}
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7 17L17 7M7 7h10v10"
/>
</svg>
</span>
</div>
<p className="text-sm font-medium text-gray-600">{hit.snippet}</p>
</Link>
))}

{filterActive && normalizedQuery && totalPages > 1 && (
<PaginationNav
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
ariaLabel="Paginación de reportes"
className="mb-4 mt-2"
/>
)}

{filterActive && normalizedQuery && totalResults === 0 && (
<NotFound />
)}

{hasSubmittedSearch && !normalizedQuery && (
<p className="px-4 pb-16 text-center font-[Nunito] text-gray-600">
Ingresa un término de búsqueda o revisa que no esté vacío.
</p>
)}
</SearchContainer>

<Footer />
</>
);
}

export default ReportsSearch;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import SearchInput from "@/presentation/shared/components/SearchInput";

interface ReportTextSearchFormProps {
query: string;
onInputChange: (event: React.InputEvent<HTMLInputElement>) => void;
onSubmit: () => void;
}

function ReportTextSearchForm({
query,
onInputChange,
onSubmit,
}: ReportTextSearchFormProps) {
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
onSubmit();
}

return (
<form
onSubmit={handleSubmit}
role="search"
className="mx-auto mb-10 flex w-full max-w-3xl flex-col px-4"
>
<label htmlFor="reports-text-search" className="sr-only">
Buscar reportes por palabras clave
</label>
<SearchInput
id="reports-text-search"
value={query}
onInput={(event) => onInputChange?.(event)}
accent="red"
placeholder="E.g. (me estafaron, perdi mi inversión...)"
/>
</form>
);
}

export default ReportTextSearchForm;
24 changes: 24 additions & 0 deletions src/presentation/pages/reports-search/mock-reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface TextReportSearchHit {
id: string;
displayId: string;
title: string;
snippet: string;
}

/** Mock data until the reports text search API exists */
export const MOCK_TEXT_REPORT_HITS: TextReportSearchHit[] = [
{
id: "1354",
displayId: "1354",
title: "¡Me estafó $2,000 MXN y estoy enojada!",
snippet:
"Estoy super enojada, me ofreció un lote de ropa por $2,000 MXN...",
},
{
id: "32",
displayId: "0032",
title: "Me pidió anticipo para una renta y se desapareció",
snippet:
"Este tipo me pidió un anticipo para apartar una casa que publicó hace d...",
},
];
5 changes: 4 additions & 1 deletion src/presentation/pages/search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ function Search() {
<Header />

<SearchContainer>
<h1 className="sr-only">Buscar reportes de fraude</h1>
{/* mt-28 clears the absolute header (h-20); matches /reportes */}
<h1 className="mb-8 mt-28 px-4 text-center font-[Nunito] text-3xl font-extrabold text-gray-900 sm:text-4xl">
Buscar reportes de fraude
</h1>
<LookupForm
onSubmit={handleSubmit}
onInputChange={handleInputChange}
Expand Down
2 changes: 1 addition & 1 deletion src/presentation/pages/search/components/LookupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function LookupForm({ onSubmit, onInputChange, query }: LookupFormProps) {
<form
onSubmit={handleSubmit}
role="search"
className="mx-auto mb-8 mt-28 flex w-full max-w-3xl flex-col px-4"
className="mx-auto mb-8 flex w-full max-w-3xl flex-col px-4"
>
<label htmlFor="report-search" className="sr-only">
Buscar por número de cuenta, tarjeta, teléfono o URL
Expand Down
4 changes: 3 additions & 1 deletion src/presentation/shared/components/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ interface SearchInputProps {
value: string;
onInput: (event: React.InputEvent<HTMLInputElement>) => void;
accent?: "orange" | "red";
placeholder?: string;
}

function SearchInput({
id,
value,
onInput,
accent = "orange",
placeholder = "Número de cuenta, tarjeta, teléfono o URL",
}: SearchInputProps) {
const focusColor =
accent === "red"
Expand All @@ -30,7 +32,7 @@ function SearchInput({
value={value}
onInput={onInput}
autoComplete="off"
placeholder="Número de cuenta, tarjeta, teléfono o URL"
placeholder={placeholder}
className="min-w-0 grow px-4 py-4 text-lg text-gray-900 outline-none placeholder:text-gray-500 font-[Nunito]"
/>
<button
Expand Down
Loading