md-platform

pay-to-view-purchases-search.md
View raw Back to list

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:

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:

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