md-platform

pay-to-view-stats.md
View raw Back to list

Pay-to-View Stats — Frontend Integration

The /channels/pay-to-view Purchases → Dashboard view previously walked every purchases page into memory (usePayToViewPurchasesAll) and re-computed every metric client-side in one big useMemo.

That is now replaced by a single endpoint that returns the whole dashboard payload, pre-aggregated server-side. The frontend renders from one small request with zero client-side aggregation.


The request

GET /studio/v1/pay-to-view/stats?interval=30d

interval query param

Value Window
24h last 24 hours
7d last 7 days
30d last 30 days (default when the param is omitted)
60d last 60 days
all all time

Any other value returns 400 with { "error": "Invalid interval. Valid values: 24h, 7d, 30d, 60d, all" }.

const { data } = await apiStudio.get<PayToViewStats>("pay-to-view/stats", {
  params: { interval }, // "24h" | "7d" | "30d" | "60d" | "all"
});

The response

{
  "interval": "30d",
  "generatedAt": "2026-07-01T09:50:00Z",
  "dateRange": { "from": "2026-06-01T09:50:00Z", "to": "2026-07-01T09:50:00Z" },

  "currency": {
    "primary": "EUR",
    "others": ["USD"],
    "revenueByCurrency": { "EUR": 1240.50, "USD": 90.00 }
  },

  "kpis": { "totalRevenue": 1240.50, "completedCount": 63, "totalCount": 81 },

  "revenueOverTime": [
    { "day": "2026-06-30", "revenue": 42.00 },
    { "day": "2026-07-01", "revenue": 18.50 }
  ],

  "providerBreakdown": [
    { "provider": 1, "count": 55, "revenue": 1100.50 },
    { "provider": 5, "count": 26, "revenue": 140.00 }
  ],

  "statusBreakdown": [
    { "status": 1, "count": 63, "revenue": 1240.50 },
    { "status": 0, "count": 12, "revenue": 0 },
    { "status": 3, "count": 6,  "revenue": 0 }
  ],

  "topContent": [
    { "entityId": 812, "entityType": 1, "name": "Big Match Replay", "revenue": 420.00, "count": 21 }
  ]
}

TypeScript shape

type Interval = "24h" | "7d" | "30d" | "60d" | "all";

interface PayToViewStats {
  interval: Interval;
  generatedAt: string;                 // ISO-8601
  dateRange: { from: string | null; to: string }; // `from` is null when interval = "all"

  currency: {
    primary: string | null;            // null when there are no completed purchases
    others: string[];                  // other completed currencies, revenue-desc
    revenueByCurrency: Record<string, number>;
  };

  kpis: {
    totalRevenue: number;              // primary-currency completed revenue only
    completedCount: number;            // completed, all currencies
    totalCount: number;                // ALL purchases, every status/currency (breakdown denominator)
  };

  revenueOverTime: { day: string; revenue: number }[]; // day = "YYYY-MM-DD", ascending

  providerBreakdown: { provider: number; count: number; revenue: number }[]; // count desc
  statusBreakdown:   { status: number;   count: number; revenue: number }[]; // count desc
  topContent: {                        // revenue desc, max 5
    entityId: number;
    entityType: number;                // 1 Video, 2 Channel, 3 Content
    name: string;
    revenue: number;
    count: number;
  }[];
}

Field reference

currency — multi-currency KPI sub-label + footnote

kpis — the two KPI cards + breakdown denominator

revenueOverTime — the area chart

Daily buckets over completed + primary-currency purchases, ascending by day, revenue already rounded to 2 dp. Build the axis label client-side:

new Date(point.day + "T00:00:00").toLocaleDateString(locale, { month: "short", day: "numeric" });

providerBreakdown — "By Provider" panel

One row per provider, count desc. Bar width = round(count / kpis.totalCount * 100). revenue is completed + primary-currency only, so a provider can show a high count with low/zero revenue.

statusBreakdown — "By Status" panel

One row per status, count desc. Only status: 1 (Completed) carries revenue and it equals kpis.totalRevenue; every other status is 0.

Parity note: today's UI renders only the Completed row. The payload includes all statuses — filter to status === 1 to reproduce current behavior exactly, or render the full breakdown if you want.

topContent — "Top Content" list (max 5)

Top earners by revenue, revenue desc, capped at 5. name already falls back to "#<entityId>" when the entity has no name. Bar width basis = revenue / topContent[0].revenue. Use entityType + entityId with getEntityRoute for clickable rows.


Client-side rendering (unchanged)

The payload carries raw ids + numbers, not display strings — same as admin analytics. Everything you do today stays on the client:

4 (Voucher) can appear in providerBreakdown if voucher purchases exist. It has no entry in PaymentProviderLabel today — guard your label lookup (PaymentProviderLabel[p] ?? "—") if you want to render it gracefully.


Migration checklist

  1. Delete the usePayToViewPurchasesAll fetch-all loop and the dashboard useMemo aggregation.
  2. Add a fetch keyed on interval (e.g. usePayToViewStats(interval)).
  3. Point each panel at its field: KPI cards → kpis + currency; area chart → revenueOverTime; provider/status panels → *Breakdown; top list → topContent.
  4. Keep every existing label/color/format/?? "EUR" guard exactly as-is.

Edge cases (guards keep the UI from throwing)

Empty / warming — no data yet: dashboard shows its empty state.

{
  "interval": "30d",
  "currency": { "primary": null, "others": [], "revenueByCurrency": {} },
  "kpis": { "totalRevenue": 0, "completedCount": 0, "totalCount": 0 },
  "revenueOverTime": [], "providerBreakdown": [], "statusBreakdown": [], "topContent": []
}

Purchases exist but none completed — KPIs/chart empty, breakdown counts still show:

{
  "interval": "7d",
  "currency": { "primary": null, "others": [], "revenueByCurrency": {} },
  "kpis": { "totalRevenue": 0, "completedCount": 0, "totalCount": 11 },
  "revenueOverTime": [],
  "providerBreakdown": [ { "provider": 1, "count": 7, "revenue": 0 }, { "provider": 5, "count": 4, "revenue": 0 } ],
  "statusBreakdown":   [ { "status": 0, "count": 8, "revenue": 0 }, { "status": 3, "count": 3, "revenue": 0 } ],
  "topContent": []
}

Consistency guarantees