# Pay-to-View Purchases — Admin Search (Frontend Guide) The admin purchases endpoint now supports a **single search input** that finds purchases by the buyer's **user ID**, **email**, **first name**, or **last name**. This doc explains how to wire it up in the admin app (`pictv-front/apps/admin`). ## Endpoint ``` GET /admin/v1/pay-to-view/purchases ``` Goes through the edge gateway like every other admin route (requires admin auth). ### Query parameters | Param | Type | Required | Description | | ------------ | -------- | -------- | ------------------------------------------------------------------ | | `page` | `number` | yes | 1-based page number | | `pageSize` | `number` | no | Defaults to `10` | | `q` | `string` | no | **The search input.** Matches user ID, email, first and last name | | `status` | `number` | no | `0` Pending, `1` Completed, `2` Refunded, `3` Failed, `4` Expired | | `entityType` | `number` | no | `1` Video, `2` Channel, `3` Content | | `productId` | `number` | no | Filter by pay-to-view product | `q` combines with the other filters (AND), so status/entityType dropdowns keep working alongside the search box. ### How `q` matches One input, four match targets — no need for separate fields or a "search by" selector: - **User ID** — exact match (paste a full user ID) - **Email** — partial, e.g. `gmail.com` or `joao@` - **First / last name** — partial, case- and accent-insensitive (`joao` matches `João`) - **Full name** — multi-word input works: `John Doe` requires every word to match name, surname, or email, so typing a full name narrows correctly ### Example requests ``` GET /admin/v1/pay-to-view/purchases?page=1&pageSize=10&q=joao GET /admin/v1/pay-to-view/purchases?page=1&q=john%20doe&status=1 GET /admin/v1/pay-to-view/purchases?page=1&q=a1b2c3d4-... (full user ID) ``` ### Response Standard pagination envelope: ```json { "items": [ { "purchaseId": "gAqW3xZ", "userId": "a1b2c3d4-...", "payToViewProductId": 5, "entityType": 1, "entityId": "kQ9rT2m", "amountPaid": 9.99, "currency": "BRL", "provider": 1, "externalTransactionId": "pi_3Nx...", "status": 1, "expiresAt": null, "createdAt": "2026-07-10T14:32:00Z", "updatedAt": null, "user": { "id": "a1b2c3d4-...", "fullName": "João Silva", "email": "joao@example.com" }, "entity": { "name": "Grand Final 2026" } } ], "totalCount": 42 } ``` Notes: - `user` is `null` if the buyer no longer exists in the auth DB. - `entity` is `null` if the purchased video/channel/content was deleted. - `provider`: `1` Stripe, `2` Apple, `3` Google, `4` Voucher, `5` Nlb. ## Frontend implementation Follow the exact same pattern as the users page (`q` param + shared `SearchInput`). Three pieces: ### 1. API client — `src/api/client/pay-to-view.ts` ```ts import { apiAdmin, ApiPaginatedResponse } from "@/api/client/base"; export type ApiPayToViewPurchase = { purchaseId: string; userId: string; payToViewProductId: number; entityType: number; // 1 Video, 2 Channel, 3 Content entityId: string; amountPaid: number; currency: string; provider: number; // 1 Stripe, 2 Apple, 3 Google, 4 Voucher, 5 Nlb externalTransactionId: string; status: number; // 0 Pending, 1 Completed, 2 Refunded, 3 Failed, 4 Expired expiresAt?: string | null; createdAt: string; updatedAt?: string | null; user?: { id: string; fullName: string; email: string } | null; entity?: { name: string } | null; }; export type PayToViewPurchasesParams = { page?: number; pageSize?: number; q?: string; status?: number; entityType?: number; productId?: number; }; export const payToViewApi = { getPurchases: (params?: PayToViewPurchasesParams): Promise> => { const page = params?.page ?? 1; const pageSize = params?.pageSize ?? 10; return apiAdmin .get("pay-to-view/purchases", { searchParams: { ...params, page, pageSize }, }) .json(); }, }; ``` ### 2. Hook — `src/api/hooks/pay-to-view.ts` Mirror `useUsers` from `src/api/hooks/users.ts`: keep filters in state, include them in the query key, and reset to page 1 whenever they change. ```ts import { useState, useCallback } from "react"; import { usePaginatedQuery } from "./use-paginated-query"; import { payToViewApi, type PayToViewPurchasesParams, type ApiPayToViewPurchase } from "@/api/client/pay-to-view"; type Filters = Omit; export function usePayToViewPurchases(initialPage = 1, initialPageSize = 10) { const [filters, setFilters] = useState({}); const paginated = usePaginatedQuery({ queryKey: (page, pageSize) => ["pay-to-view", "purchases", page, pageSize, JSON.stringify(filters)], queryFn: (page, pageSize) => { const params: PayToViewPurchasesParams = { page, pageSize }; if (filters.q?.trim()) params.q = filters.q.trim(); if (filters.status !== undefined) params.status = filters.status; if (filters.entityType !== undefined) params.entityType = filters.entityType; if (filters.productId !== undefined) params.productId = filters.productId; return payToViewApi.getPurchases(params); }, initialPage, initialPageSize, }); const updateFilters = useCallback( (newFilters: Filters) => { setFilters((prev) => ({ ...prev, ...newFilters })); paginated.handlePageChange(1); // back to page 1 on any filter change }, [paginated.handlePageChange], ); return { ...paginated, filters, updateFilters }; } ``` ### 3. Page — one search input Use the shared `SearchInput` (`src/components/search/search.tsx`) — it already debounces (500 ms default) and handles clear/Escape, so every keystroke does **not** hit the API: ```tsx import SearchInput from "@/components/search/search"; import { usePayToViewPurchases } from "@/api/hooks/pay-to-view"; export default function PurchasesPage() { const { items, totalCount, isLoading, updateFilters /* , page, handlePageChange, ... */ } = usePayToViewPurchases(); return (
updateFilters({ q })} /> {/* results table + pagination */}
); } ``` ## UX checklist - **One input, no "search by" dropdown** — the backend decides what matched. - **Debounce** — keep the `SearchInput` default (500 ms); don't set `debounceMs={0}`. - **Reset to page 1** on every search change (the hook above does this). - **Empty search** (`q` cleared) must drop the param entirely, not send `q=` — the `filters.q?.trim()` guard handles it. - **Empty state** — show "No purchases found for '…'" when `items` is empty and `q` is set, so admins know the search worked but matched nothing. - Display `user.fullName` and `user.email` in the results table; fall back to `userId` when `user` is `null` (deleted account).