# 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 **`apiStudio`** client (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" }`. ```ts const { data } = await apiStudio.get("pay-to-view/stats", { params: { interval }, // "24h" | "7d" | "30d" | "60d" | "all" }); ``` --- ## The response ```jsonc { "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 ```ts 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; }; 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 be `null`** when 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` — completed `amountPaid` summed per currency. `revenueByCurrency[currency.primary]` equals `kpis.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: ```ts 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 `"#"` 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]` (`1` Stripe, `2` Apple, `3` Google, `5` NLB), `PurchaseStatusLabel[status]` (`0` Pending, `1` Completed, `2` Refunded, `3` Failed, `4` Expired), entity-type labels. - **Colors** — `BRAND` for provider rows, `statusBarColor[status]` for status rows. - **Currency formatting** — `formatPrice(amount, currency.primary ?? "EUR", locale)`. - **Chart date labels** — `toLocaleDateString` from `day`. > **`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. ```json { "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: ```json { "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[*].count` sums to `kpis.totalCount`; `providerBreakdown[*].revenue` sums to `kpis.totalRevenue`. - `statusBreakdown[*].count` sums to `kpis.totalCount`; only `status: 1` carries revenue, equal to `kpis.totalRevenue`. - `revenueOverTime[*].revenue` sums to ≈ `kpis.totalRevenue` (per-day 2-dp rounding may drift a cent). - `currency.revenueByCurrency[currency.primary]` equals `kpis.totalRevenue`. - `topContent[*].revenue` each ≤ `kpis.totalRevenue`, sorted desc, ≤ 5 rows. - All breakdown/list ordering is **deterministic** (ties broken by id), so it's stable across refetches.