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
90 changes: 89 additions & 1 deletion .cursor/skills/pagination/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: pagination
description: Implements and maintains server-driven React pagination using this project's Search page and Report Contactos tab patterns. Use when adding pagination, changing page navigation, synchronizing search pages with URL parameters, resetting pagination after filters, or working with paginated API results.
description: Implements and maintains server-driven React pagination and the shared paginated list-row card layout (ContactCard, PaymentMethodCard, PartyReportCard, AssociationCard). Use when adding pagination, new report-tab lists, changing page navigation, synchronizing search pages with URL parameters, resetting pagination after filters, or working with paginated API results.
---

# Frontend Pagination
Expand Down Expand Up @@ -88,6 +88,91 @@ totalPages: number;
onPageChange: (page: number) => void;
```

## Paginated list row cards

Profile tabs that paginate entities (Contactos, Métodos de pago, Reportes,
Colaboradores/Empresas, etc.) render **one card per row** inside a scrollable
list, then `PaginationNav` below. Match the layout already used in:

- `src/presentation/pages/report/components/ContactCard.tsx`
- `src/presentation/pages/report/components/PaymentMethodCard.tsx`
- `src/presentation/shared/components/PartyReportCard.tsx`
- `src/presentation/pages/report/components/AssociationCard.tsx` (same two-row
rhythm; optional uppercase kind label on row 1)

Do not introduce a third card layout for paginated report-tab lists. Reuse an
existing card or extend it with props; copy structure and Tailwind classes from
the references above.

### List container (inside the tab section)

```tsx
<div className="mt-6 max-h-[30rem] space-y-3 overflow-y-scroll pr-1">
{items.map((item) => (
<SomeCard key={item.id} … />
))}
</div>
```

Mirror `ContactsTab`, `ReportsTab`, `PaymentMethodsTab`, and `AssociationsTab`.

### Card shell

- Wrap each row in `<article>`.
- Interactive surface: `<a>`, `<Link>`, or `<button type="button">` with
`className` including at least:
`block w-full cursor-pointer rounded-md border border-gray-200 px-4 py-3
text-left hover:bg-gray-50` (payment copy cards may add copy-state borders;
keep the same padding and radius).
- Use `min-w-0` + `truncate` on primary text; set `title` when truncation is
likely.

### Two-row content (required)

**Row 1 — header** (`flex items-start justify-between gap-3`):

- Leading cluster: optional icon (platform/payment) + title (`h3`, `text-sm
font-bold` or `font-extrabold`, `text-gray-900`), **or** for associations an
uppercase kind label (`text-xs font-bold uppercase tracking-wide
text-gray-500`) with name on row 2 instead.
- Trailing affordance when navigation applies: external-link icon (`aria-hidden`
on decorative icons). Payment cards use a copy feedback badge in this corner
instead when copying.

**Row 2 — detail + date** (always this wrapper):

```tsx
<div className="mt-3 flex flex-col gap-2 text-sm text-gray-500 sm:flex-row sm:items-center sm:justify-between">
{/* left: secondary value — icon + truncate */}
{/* right: CalendarIcon + <time dateTime={…}> */}
</div>
```

- Left: secondary field (URL, account reference, description, or primary name
when row 1 is only a kind label). Pair with a small gray icon when it helps
(link, copy, etc.).
- Right: calendar icon (`h-4 w-4 shrink-0 text-gray-400`) +
`formatContactDate(createdAt)` inside `<time dateTime={iso}>`.
- On small viewports the row stacks (`flex-col`); from `sm:` onward, detail and
date sit on one line (`justify-between`).

### Dates and entities

- Format every list date with `formatContactDate` from
`contact-date.util.ts`.
- If the API exposes `created_at`, map it on the entity (see `PartyReportEntity`
+ `public-api.adapter.ts`) so cards can show the calendar row consistently.

### When adding a new paginated tab

1. Implement the tab fetch/pagination like `ContactsTab` (this skill’s earlier
sections).
2. Render rows with an existing card component or the same two-row markup.
3. Place `PaginationNav` only when `!isLoading && !errorMessage` (and when
`totalPages >= 1` per component rules).
4. Before shipping, compare the new UI side-by-side with Contactos and Reportes
in the browser; row structure and spacing should match.

## Checklist

- Paginated use case accepts the requested page.
Expand All @@ -97,3 +182,6 @@ onPageChange: (page: number) => void;
- Invalid and redundant navigation is ignored.
- Previous/next disabled states and active-page accessibility are present.
- Loading, empty, cancellation, and stale-response states are handled.
- Paginated lists use `max-h-[30rem] space-y-3 overflow-y-scroll pr-1` and row
cards that follow the two-row ContactCard / PaymentMethodCard /
PartyReportCard layout (detail left, calendar date right on `sm+`).
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const extraReports = Array.from({ length: 22 }, (_, index) => {
id,
`Reporte de estafa #${id}`,
`Detalle del reporte ${id}: me contactaron ofreciendo un trato que parecía legítimo y luego desaparecieron con el dinero.`,
"2026-11-20",
);
});

Expand All @@ -19,16 +20,19 @@ const stubReports: PartyReportEntity[] = [
"1354",
"¡Me estafó $2,000 MXN y estoy enojada!",
"Estoy super enojada, me ofreció un lote de ropa por $2,000 MXN y le deposité en oxxo sin verificar.",
"2026-11-20",
),
new PartyReportEntity(
"1314",
"Estafado",
"Hola, quiero reportar a este tipo que me estafó 1000 pesos. Pensé que era real y le creí.",
"2026-11-18",
),
new PartyReportEntity(
"1287",
"Cuidado parece estafa!",
"El titular de la cuenta es MARISOL DOMINGUEZ NUEVO y el tipo quiere que pongamos el dinero en su cuenta.",
"2026-11-15",
),
...extraReports,
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe("PartyReportEntity.success", () => {
"1354",
"¡Me estafó $2,000 MXN y estoy enojada!",
"Estoy super enojada, me ofreció un lote de ropa por $2,000 MXN.",
"2026-10-07",
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/core/domain/report/entities/party-report.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class PartyReportEntity {
private readonly _id: string,
private readonly _title: string,
private readonly _description: string,
private readonly _createdAt: string,
) {}

public get id(): string {
Expand All @@ -16,6 +17,10 @@ class PartyReportEntity {
public get description(): string {
return this._description;
}

public get createdAt(): string {
return this._createdAt;
}
}

export default PartyReportEntity;
2 changes: 2 additions & 0 deletions src/infrastructure/api/public-api.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class PublicApiAdapter implements PublicApiPort {
String(report.id),
report.title,
report.short_description ?? "",
report.created_at,
),
),
total: data.total,
Expand Down Expand Up @@ -549,6 +550,7 @@ class PublicApiAdapter implements PublicApiPort {
String(report.id),
report.title,
report.short_description,
report.created_at,
),
),
total: data.total,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@ const FORM_COPY: Record<
"Los perfiles/números que haya utilizado el colaborador para contactarte",
},
organization: {
photoAdd: "Agregar foto de la organización",
photoChange: "Cambiar foto de la organización",
name: "Nombre de la organización",
photoAdd: "Agregar foto de la empresa",
photoChange: "Cambiar foto de la empresa",
name: "Nombre de la empresa",
placeholder: "Ej. (Billions Trade Club, Globoshop)",
payments:
"Los números de cuenta/bancos/wallets que esté utilizando la organización para captar fondos",
"Los números de cuenta/bancos/wallets que esté utilizando la empresa para captar fondos",
contacts:
"Los perfiles/números que haya utilizado la organización para contactarte",
"Los perfiles/números que haya utilizado la empresa para contactarte",
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ function PartyPhotoInput({
required = true,
}: PartyPhotoInputProps) {
const { storeTemporaryProfilePictureUseCase } = useDependencies();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const requestId = useRef(0);
const isMounted = useRef(true);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const previewFile = pendingFile ?? file;
const displayPreviewUrl = previewFile ? previewUrl : null;
const label = previewFile ? changeLabel : addLabel;
const circleBorder = invalid ? "border-red-600" : "border-gray-400";
const viewLabel = addLabel.replace(/^Agregar /, "Ver ");
Expand All @@ -56,11 +57,12 @@ function PartyPhotoInput({

useEffect(() => {
if (!previewFile) {
setPreviewUrl(null);
return;
}

const nextUrl = URL.createObjectURL(previewFile);
// Blob preview URLs are created in an effect so the prior URL can be revoked.
// eslint-disable-next-line react-hooks/set-state-in-effect -- object URL lifecycle
setPreviewUrl(nextUrl);

return () => {
Expand Down Expand Up @@ -138,7 +140,7 @@ function PartyPhotoInput({
return (
<div className="flex flex-col items-center">
<div className="relative">
{previewUrl ? (
{displayPreviewUrl ? (
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
Expand All @@ -149,7 +151,7 @@ function PartyPhotoInput({
className={`relative flex h-28 w-28 cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed p-0 text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600 ${circleBorder}`}
>
<img
src={previewUrl}
src={displayPreviewUrl}
alt=""
className="h-full w-full object-cover"
/>
Expand All @@ -171,7 +173,7 @@ function PartyPhotoInput({
<input
id={id}
type="file"
accept="image/jpeg,image/png"
accept="image/jpeg,image/png,image/webp"
aria-label={label}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
Expand All @@ -196,9 +198,9 @@ function PartyPhotoInput({
</button>
) : null}
</div>
{isPreviewOpen && previewUrl ? (
{isPreviewOpen && displayPreviewUrl ? (
<ImageLightbox
src={previewUrl}
src={displayPreviewUrl}
alt={viewLabel}
onClose={closePreview}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/presentation/pages/report/Report.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ describe("Report page", () => {
</DependencyProvider>,
);

await user.click(await screen.findByRole("tab", { name: "Estafadores" }));
await user.click(await screen.findByRole("tab", { name: "Colaboradores" }));
expect(
await screen.findByRole("link", { name: /Ada Lovelace/ }),
).toBeInTheDocument();
Expand Down
21 changes: 17 additions & 4 deletions src/presentation/pages/report/Report.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ function Report({ type }: { type: "scammer" | "organization" }) {
useDependencies();
const profileKey = `${type}:${id ?? ""}`;
const [activeTab, setActiveTab] = useState<ReportTab>("General");
const [viewingReportId, setViewingReportId] = useState<string | null>(null);
const [activeProfileKey, setActiveProfileKey] = useState(profileKey);
const [profilePictureOverride, setProfilePictureOverride] =
useState<ProfilePictureUploadResult | null>(null);
Expand All @@ -100,8 +101,14 @@ function Report({ type }: { type: "scammer" | "organization" }) {
setActiveProfileKey(profileKey);
setActiveTab("General");
setProfilePictureOverride(null);
setViewingReportId(null);
}

const handleTabChange = (tab: ReportTab) => {
setActiveTab(tab);
setViewingReportId(null);
};

useLayoutEffect(() => {
scrollViewToTop();
}, [profileKey]);
Expand Down Expand Up @@ -206,7 +213,7 @@ function Report({ type }: { type: "scammer" | "organization" }) {
<ReportTabNavigation
partyType={type}
activeTab={activeTab}
onTabChange={setActiveTab}
onTabChange={handleTabChange}
/>
)}

Expand Down Expand Up @@ -262,19 +269,25 @@ function Report({ type }: { type: "scammer" | "organization" }) {
partyId={party.id}
partyType={type}
reports={party.reports}
onNavigateTab={setActiveTab}
onNavigateTab={handleTabChange}
/>
)}
{activeTab === "Reportes" && (
<ReportsTab partyId={party.id} partyType={type} />
<ReportsTab
partyId={party.id}
partyType={type}
selectedReportId={viewingReportId}
onSelectReport={setViewingReportId}
onBackFromReport={() => setViewingReportId(null)}
/>
)}
{activeTab === "Contactos" && (
<ContactsTab partyId={party.id} partyType={type} />
)}
{activeTab === "Métodos de pago" && (
<PaymentMethodsTab partyId={party.id} partyType={type} />
)}
{(activeTab === "Empresas" || activeTab === "Estafadores") && (
{(activeTab === "Empresas" || activeTab === "Colaboradores") && (
<AssociationsTab
key={`${type}:${party.id}`}
partyId={party.id}
Expand Down
Loading
Loading