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
- Use the existing
apiStudioclient (creator-authenticated — the payload is scoped to the signed-in creator's channel automatically). - Call it once per view, re-fetching only when the user changes the interval.
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
primary— currency with the highest completed revenue. May benullwhen the window has no completed purchases → keep your?? "EUR"fallback.others— the other completed currencies, revenue-desc. Empty ⇒ show "from completed"; non-empty ⇒ show the "+ other currencies" label and the multi-currency footnote.revenueByCurrency— completedamountPaidsummed per currency.revenueByCurrency[currency.primary]equalskpis.totalRevenue.
kpis — the two KPI cards + breakdown denominator
totalRevenue— primary-currency completed revenue only; never mixes currencies. Card 1 value =formatPrice(totalRevenue, currency.primary ?? "EUR").completedCount— completed across all currencies. Card 2 value.totalCount— every purchase (all statuses, all currencies) in the window. Card 2 sub-label total, and the%denominator for every breakdown bar.
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
Completedrow. The payload includes all statuses — filter tostatus === 1to 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:
- Labels —
PaymentProviderLabel[provider](1Stripe,2Apple,3Google,5NLB),PurchaseStatusLabel[status](0Pending,1Completed,2Refunded,3Failed,4Expired), entity-type labels. - Colors —
BRANDfor provider rows,statusBarColor[status]for status rows. - Currency formatting —
formatPrice(amount, currency.primary ?? "EUR", locale). - Chart date labels —
toLocaleDateStringfromday.
4(Voucher) can appear inproviderBreakdownif voucher purchases exist. It has no entry inPaymentProviderLabeltoday — guard your label lookup (PaymentProviderLabel[p] ?? "—") if you want to render it gracefully.
Migration checklist
- Delete the
usePayToViewPurchasesAllfetch-all loop and the dashboarduseMemoaggregation. - Add a fetch keyed on
interval(e.g.usePayToViewStats(interval)). - Point each panel at its field: KPI cards →
kpis+currency; area chart →revenueOverTime; provider/status panels →*Breakdown; top list →topContent. - 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
providerBreakdown[*].countsums tokpis.totalCount;providerBreakdown[*].revenuesums tokpis.totalRevenue.statusBreakdown[*].countsums tokpis.totalCount; onlystatus: 1carries revenue, equal tokpis.totalRevenue.revenueOverTime[*].revenuesums to ≈kpis.totalRevenue(per-day 2-dp rounding may drift a cent).currency.revenueByCurrency[currency.primary]equalskpis.totalRevenue.topContent[*].revenueeach ≤kpis.totalRevenue, sorted desc, ≤ 5 rows.- All breakdown/list ordering is deterministic (ties broken by id), so it's stable across refetches.