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.comorjoao@ - First / last name — partial, case- and accent-insensitive (
joaomatchesJoão) - Full name — multi-word input works:
John Doerequires 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:
{
"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": "[email protected]"
},
"entity": { "name": "Grand Final 2026" }
}
],
"totalCount": 42
}
Notes:
userisnullif the buyer no longer exists in the auth DB.entityisnullif the purchased video/channel/content was deleted.provider:1Stripe,2Apple,3Google,4Voucher,5Nlb.
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
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<ApiPaginatedResponse<ApiPayToViewPurchase>> => {
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.
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<PayToViewPurchasesParams, "page" | "pageSize">;
export function usePayToViewPurchases(initialPage = 1, initialPageSize = 10) {
const [filters, setFilters] = useState<Filters>({});
const paginated = usePaginatedQuery<ApiPayToViewPurchase>({
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:
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 (
<div>
<SearchInput
placeholder="Search by user ID, email or name"
onChange={(q) => updateFilters({ q })}
/>
{/* results table + pagination */}
</div>
);
}
UX checklist
- One input, no "search by" dropdown — the backend decides what matched.
- Debounce — keep the
SearchInputdefault (500 ms); don't setdebounceMs={0}. - Reset to page 1 on every search change (the hook above does this).
- Empty search (
qcleared) must drop the param entirely, not sendq=— thefilters.q?.trim()guard handles it. - Empty state — show "No purchases found for '…'" when
itemsis empty andqis set, so admins know the search worked but matched nothing. - Display
user.fullNameanduser.emailin the results table; fall back touserIdwhenuserisnull(deleted account).