@withpotter/pages-admin 0.13.0-next.101 → 0.13.0-next.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/admin/hooks.js +19 -0
  2. package/dist/admin/hooks.js.map +1 -1
  3. package/dist/admin/revenue/cohort-panel.js +5 -5
  4. package/dist/admin/revenue/cohort-panel.js.map +1 -1
  5. package/dist/admin/revenue/health-panel.js +17 -17
  6. package/dist/admin/revenue/health-panel.js.map +1 -1
  7. package/dist/admin/revenue/ledger-model.js +7 -0
  8. package/dist/admin/revenue/ledger-model.js.map +1 -1
  9. package/dist/admin/revenue/ledger.module.css +43 -0
  10. package/dist/admin/revenue/merchant-headline.js +37 -0
  11. package/dist/admin/revenue/merchant-headline.js.map +1 -0
  12. package/dist/admin/revenue/merchant-payments-view.js +46 -22
  13. package/dist/admin/revenue/merchant-payments-view.js.map +1 -1
  14. package/dist/admin/revenue/merchants-view.js +99 -0
  15. package/dist/admin/revenue/merchants-view.js.map +1 -0
  16. package/dist/admin/revenue/payment-drawer.js +53 -0
  17. package/dist/admin/revenue/payment-drawer.js.map +1 -0
  18. package/dist/admin/revenue/plan-mix-panel.js +1 -1
  19. package/dist/admin/revenue/plan-mix-panel.js.map +1 -1
  20. package/dist/admin/revenue/plan-payment-drawer.js +42 -0
  21. package/dist/admin/revenue/plan-payment-drawer.js.map +1 -0
  22. package/dist/admin/revenue/print-summary-button.js +34 -0
  23. package/dist/admin/revenue/print-summary-button.js.map +1 -0
  24. package/dist/admin/revenue/quality-panel.js +19 -19
  25. package/dist/admin/revenue/quality-panel.js.map +1 -1
  26. package/dist/admin/revenue/revenue-kpis.js +64 -50
  27. package/dist/admin/revenue/revenue-kpis.js.map +1 -1
  28. package/dist/admin/revenue/revenue-overview.js +68 -50
  29. package/dist/admin/revenue/revenue-overview.js.map +1 -1
  30. package/dist/admin/revenue/revenue-retention.js +17 -13
  31. package/dist/admin/revenue/revenue-retention.js.map +1 -1
  32. package/dist/admin/revenue/revenue-workspace.js +16 -0
  33. package/dist/admin/revenue/revenue-workspace.js.map +1 -1
  34. package/dist/admin/revenue/subscriptions-panel.js +14 -14
  35. package/dist/admin/revenue/subscriptions-panel.js.map +1 -1
  36. package/dist/admin/revenue/subscriptions-view.js +20 -20
  37. package/dist/admin/revenue/subscriptions-view.js.map +1 -1
  38. package/dist/admin/revenue/summary-download.js +34 -0
  39. package/dist/admin/revenue/summary-download.js.map +1 -0
  40. package/dist/admin/revenue/summary-print.js +82 -0
  41. package/dist/admin/revenue/summary-print.js.map +1 -0
  42. package/dist/admin/revenue/summary-rows.js +44 -0
  43. package/dist/admin/revenue/summary-rows.js.map +1 -0
  44. package/dist/admin/revenue/top-tenants-panel.js +1 -1
  45. package/dist/admin/revenue/top-tenants-panel.js.map +1 -1
  46. package/dist/admin/revenue/url-filters.js +36 -0
  47. package/dist/admin/revenue/url-filters.js.map +1 -0
  48. package/dist/admin/revenue/use-url-filters.js +24 -0
  49. package/dist/admin/revenue/use-url-filters.js.map +1 -0
  50. package/package.json +4 -4
@@ -18,9 +18,12 @@ import {
18
18
  word
19
19
  } from "./ledger-model";
20
20
  import { PlanMixPanel } from "./plan-mix-panel";
21
+ import { PlanPaymentDrawer } from "./plan-payment-drawer";
22
+ import { useUrlFilters } from "./use-url-filters";
21
23
  import styles from "./ledger.module.css";
22
24
  import table from "./revenue.module.css";
23
25
  const PAGE_SIZE = 25;
26
+ const FILTERS = { q: "", status: "", method: "" };
24
27
  const integer = new Intl.NumberFormat("en-US");
25
28
  const STATUSES = [
26
29
  { value: "", label: "Every result" },
@@ -40,23 +43,19 @@ function SubscriptionsView({
40
43
  endDate,
41
44
  revenue
42
45
  }) {
43
- const [search, setSearch] = useState("");
44
- const [q, setQ] = useState("");
45
- const [status, setStatus] = useState("");
46
- const [method, setMethod] = useState("");
47
- const [page, setPage] = useState(1);
46
+ const { values, page, set } = useUrlFilters("sp", FILTERS);
47
+ const [search, setSearch] = useState(values.q);
48
+ const [open, setOpen] = useState(null);
48
49
  useEffect(() => {
49
- const timer = setTimeout(() => setQ(search.trim()), 300);
50
+ const timer = setTimeout(() => {
51
+ if (search.trim() !== values.q) set({ q: search.trim() });
52
+ }, 300);
50
53
  return () => clearTimeout(timer);
51
54
  }, [search]);
52
55
  const filters = useMemo(
53
- () => ({ startDate, endDate, status, method, q, page, limit: PAGE_SIZE }),
54
- [startDate, endDate, status, method, q, page]
56
+ () => ({ startDate, endDate, status: values.status, method: values.method, q: values.q, page, limit: PAGE_SIZE }),
57
+ [startDate, endDate, values, page]
55
58
  );
56
- const change = (set) => (value) => {
57
- set(value);
58
- setPage(1);
59
- };
60
59
  const { recurring } = revenue;
61
60
  const tracked = recurring.tracked !== false;
62
61
  const currency = recurring.currency ?? revenue.currency ?? "NGN";
@@ -118,13 +117,13 @@ function SubscriptionsView({
118
117
  FilterSearch,
119
118
  {
120
119
  value: search,
121
- onChange: change(setSearch),
120
+ onChange: setSearch,
122
121
  placeholder: "Search a merchant's name or email, or a reference",
123
122
  label: "Search plan payments"
124
123
  }
125
124
  ),
126
- /* @__PURE__ */ jsx(FilterSelect, { value: status, onChange: change(setStatus), options: STATUSES, label: "Payment result" }),
127
- /* @__PURE__ */ jsx(FilterSelect, { value: method, onChange: change(setMethod), options: METHODS, label: "Way of paying" })
125
+ /* @__PURE__ */ jsx(FilterSelect, { value: values.status, onChange: (status) => set({ status }), options: STATUSES, label: "Payment result" }),
126
+ /* @__PURE__ */ jsx(FilterSelect, { value: values.method, onChange: (method) => set({ method }), options: METHODS, label: "Way of paying" })
128
127
  ] }),
129
128
  /* @__PURE__ */ jsx(
130
129
  DownloadButton,
@@ -148,7 +147,7 @@ function SubscriptionsView({
148
147
  /* @__PURE__ */ jsx(Th, { children: "Result" }),
149
148
  /* @__PURE__ */ jsx(Th, { align: "right", children: "Amount" })
150
149
  ] }),
151
- children: payments.data.items.map((row) => /* @__PURE__ */ jsx(PlanPaymentRow, { row }, row.id))
150
+ children: payments.data.items.map((row) => /* @__PURE__ */ jsx(PlanPaymentRow, { row, onOpen: setOpen }, row.id))
152
151
  }
153
152
  ),
154
153
  /* @__PURE__ */ jsx(
@@ -158,15 +157,16 @@ function SubscriptionsView({
158
157
  totalPages: Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1),
159
158
  total: payments.data.total,
160
159
  noun: "plan payments",
161
- onPage: setPage
160
+ onPage: (next) => set({ page: next })
162
161
  }
163
- )
162
+ ),
163
+ /* @__PURE__ */ jsx(PlanPaymentDrawer, { row: open, onClose: () => setOpen(null) })
164
164
  ] })
165
165
  ] });
166
166
  }
167
- function PlanPaymentRow({ row }) {
167
+ function PlanPaymentRow({ row, onOpen }) {
168
168
  const when = row.paidAt ?? row.createdAt;
169
- return /* @__PURE__ */ jsxs(TableRow, { children: [
169
+ return /* @__PURE__ */ jsxs(TableRow, { onClick: () => onOpen(row), children: [
170
170
  /* @__PURE__ */ jsx("td", { className: table.num, children: new Date(when).toLocaleString(void 0, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) }),
171
171
  /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsxs("span", { className: styles.who, children: [
172
172
  /* @__PURE__ */ jsx("span", { className: styles.whoName, children: row.merchantName || "Unknown merchant" }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/subscriptions-view.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport {\n platformAdminApi,\n type PlatformRevenue,\n type SubscriptionPaymentRow,\n} from '@withpotter/lib/admin-api';\nimport { useSubscriptionPayments } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport { CountUp, Reveal } from '../_fx';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination, StatCard, moneyIn } from '../_ui';\nimport { DownloadButton } from './download-button';\nimport {\n PURPOSE_WORDS,\n STATUS_WORDS,\n SUBSCRIPTION_METHOD_WORDS,\n downloadName,\n minorToMoney,\n word,\n} from './ledger-model';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport styles from './ledger.module.css';\nimport table from './revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst integer = new Intl.NumberFormat('en-US');\n\nconst STATUSES = [\n { value: '', label: 'Every result' },\n { value: 'succeeded', label: 'Paid' },\n { value: 'failed', label: 'Failed' },\n { value: 'pending', label: 'Waiting' },\n];\nconst METHODS = [\n { value: '', label: 'Every way of paying' },\n { value: 'gateway', label: 'Card or bank' },\n { value: 'wallet', label: 'Wallet' },\n { value: 'manual', label: 'Recorded by hand' },\n];\n\nconst tone = (status: string) =>\n status === 'succeeded' ? styles.statusGood : status === 'failed' ? styles.statusBad : styles.statusWait;\n\n/** What merchants pay Olive for their plan: the income figures, who pays what, and every plan payment. */\nexport function SubscriptionsView({\n startDate,\n endDate,\n revenue,\n}: {\n startDate?: string;\n endDate?: string;\n revenue: PlatformRevenue;\n}) {\n const [search, setSearch] = useState('');\n const [q, setQ] = useState('');\n const [status, setStatus] = useState('');\n const [method, setMethod] = useState('');\n const [page, setPage] = useState(1);\n\n useEffect(() => {\n const timer = setTimeout(() => setQ(search.trim()), 300);\n\n return () => clearTimeout(timer);\n }, [search]);\n\n const filters = useMemo(\n () => ({ startDate, endDate, status, method, q, page, limit: PAGE_SIZE }),\n [startDate, endDate, status, method, q, page],\n );\n const change = <T,>(set: (v: T) => void) => (value: T) => {\n set(value);\n setPage(1);\n };\n\n const { recurring } = revenue;\n const tracked = recurring.tracked !== false;\n const currency = recurring.currency ?? revenue.currency ?? 'NGN';\n const money = moneyIn(currency);\n const money2 = moneyIn(currency, 2);\n const notSet = 'Not set up yet';\n const why = 'The plan payments have not been connected to this page yet.';\n const payments = useSubscriptionPayments(filters);\n\n return (\n <div>\n <p className={styles.lede}>\n This is money that merchants pay Olive for their plan. It is kept apart from the money customers pay merchants.\n </p>\n\n <section aria-label=\"Plan income\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={tracked ? <CountUp value={recurring.mrr} format={money} /> : notSet}\n sub={tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : why}\n hint=\"What merchants on a paid plan are expected to pay Olive every month. Merchants on a free plan, a trial or a pause are not counted. (MRR)\"\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={tracked ? <CountUp value={recurring.arr} format={money} /> : notSet}\n sub={tracked ? 'Each month times 12' : why}\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12. (ARR)\"\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={tracked ? <CountUp value={recurring.arpa} format={money2} /> : notSet}\n sub={tracked ? 'Each month' : why}\n hint=\"What one paying merchant pays Olive in a month, on average. (ARPA)\"\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Joined and left\"\n value={tracked ? `${integer.format(recurring.newInRange)} joined` : notSet}\n sub={tracked ? `${integer.format(recurring.churnedInRange)} left or cancelled in this period` : why}\n hint=\"Merchants who started a plan in this period, and merchants whose plan was cancelled, ended or switched off.\"\n />\n </Reveal>\n </section>\n\n {tracked && recurring.planBreakdown.length > 0 ? (\n <>\n <h2 className={styles.sectionTitle}>Who pays what</h2>\n <p className={styles.sectionNote}>How the monthly plan income splits across plans.</p>\n <PlanMixPanel planBreakdown={recurring.planBreakdown} money={money} caption={`Each month · ${currency}`} />\n </>\n ) : null}\n\n <h2 className={styles.sectionTitle}>Every plan payment</h2>\n {payments.isError ? (\n <div className={styles.notice}>\n Plan payments are not available on this box yet. Once the subscription module is connected, every payment will\n be listed here.\n </div>\n ) : payments.isLoading || !payments.data ? (\n <AdmLoading label=\"Loading plan payments…\" />\n ) : (\n <>\n <div className={styles.toolbar}>\n <Filters label=\"Filter plan payments\">\n <FilterSearch\n value={search}\n onChange={change(setSearch)}\n placeholder=\"Search a merchant's name or email, or a reference\"\n label=\"Search plan payments\"\n />\n <FilterSelect value={status} onChange={change(setStatus)} options={STATUSES} label=\"Payment result\" />\n <FilterSelect value={method} onChange={change(setMethod)} options={METHODS} label=\"Way of paying\" />\n </Filters>\n <DownloadButton\n filename={downloadName('plan-payments', startDate, endDate)}\n load={() => platformAdminApi.subscriptionPaymentsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {payments.data.items.length === 0 ? (\n <EmptyNote className={table.empty}>No plan payments match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={860}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Merchant</Th>\n <Th>Plan</Th>\n <Th>For</Th>\n <Th>How</Th>\n <Th>Result</Th>\n <Th align=\"right\">Amount</Th>\n </tr>\n }\n >\n {payments.data.items.map((row) => (\n <PlanPaymentRow key={row.id} row={row} />\n ))}\n </TableShell>\n )}\n <Pagination\n page={page}\n totalPages={Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1)}\n total={payments.data.total}\n noun=\"plan payments\"\n onPage={setPage}\n />\n </>\n )}\n </div>\n );\n}\n\nfunction PlanPaymentRow({ row }: { row: SubscriptionPaymentRow }) {\n const when = row.paidAt ?? row.createdAt;\n\n return (\n <TableRow>\n <td className={table.num}>\n {new Date(when).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span className={styles.who}>\n <span className={styles.whoName}>{row.merchantName || 'Unknown merchant'}</span>\n {row.merchantEmail ? <span className={styles.whoSub}>{row.merchantEmail}</span> : null}\n </span>\n </td>\n <td className={table.name}>{row.plan.charAt(0).toUpperCase() + row.plan.slice(1)}</td>\n <td>{word(PURPOSE_WORDS, row.purpose)}</td>\n <td>{word(SUBSCRIPTION_METHOD_WORDS, row.method)}</td>\n <td>\n <span className={styles.who}>\n <span className={`${styles.status} ${tone(row.status)}`}>{word(STATUS_WORDS, row.status)}</span>\n {row.status === 'failed' && row.failureReason ? <span className={styles.whoSub}>{row.failureReason}</span> : null}\n </span>\n </td>\n <td className={table.numStrong}>{minorToMoney(row.amountKobo, 'NGN')}</td>\n </TableRow>\n );\n}\n"],"mappings":";AAuFM,SAwCE,UAxCF,KAIA,YAJA;AArFN,SAAS,WAAW,SAAS,gBAAgB;AAC7C,SAAS,WAAW,UAAU,YAAY,UAAU;AACpD;AAAA,EACE;AAAA,OAGK;AACP,SAAS,+BAA+B;AAExC,SAAS,SAAS,cAAc;AAChC,SAAS,YAAY,cAAc,cAAc,SAAS,YAAY,UAAU,eAAe;AAC/F,SAAS,sBAAsB;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAC7B,OAAO,YAAY;AACnB,OAAO,WAAW;AAElB,MAAM,YAAY;AAClB,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAE7C,MAAM,WAAW;AAAA,EACf,EAAE,OAAO,IAAI,OAAO,eAAe;AAAA,EACnC,EAAE,OAAO,aAAa,OAAO,OAAO;AAAA,EACpC,EAAE,OAAO,UAAU,OAAO,SAAS;AAAA,EACnC,EAAE,OAAO,WAAW,OAAO,UAAU;AACvC;AACA,MAAM,UAAU;AAAA,EACd,EAAE,OAAO,IAAI,OAAO,sBAAsB;AAAA,EAC1C,EAAE,OAAO,WAAW,OAAO,eAAe;AAAA,EAC1C,EAAE,OAAO,UAAU,OAAO,SAAS;AAAA,EACnC,EAAE,OAAO,UAAU,OAAO,mBAAmB;AAC/C;AAEA,MAAM,OAAO,CAAC,WACZ,WAAW,cAAc,OAAO,aAAa,WAAW,WAAW,OAAO,YAAY,OAAO;AAGxF,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,EAAE;AACvC,QAAM,CAAC,GAAG,IAAI,IAAI,SAAS,EAAE;AAC7B,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,EAAE;AACvC,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,EAAE;AACvC,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,CAAC;AAElC,YAAU,MAAM;AACd,UAAM,QAAQ,WAAW,MAAM,KAAK,OAAO,KAAK,CAAC,GAAG,GAAG;AAEvD,WAAO,MAAM,aAAa,KAAK;AAAA,EACjC,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,UAAU;AAAA,IACd,OAAO,EAAE,WAAW,SAAS,QAAQ,QAAQ,GAAG,MAAM,OAAO,UAAU;AAAA,IACvE,CAAC,WAAW,SAAS,QAAQ,QAAQ,GAAG,IAAI;AAAA,EAC9C;AACA,QAAM,SAAS,CAAK,QAAwB,CAAC,UAAa;AACxD,QAAI,KAAK;AACT,YAAQ,CAAC;AAAA,EACX;AAEA,QAAM,EAAE,UAAU,IAAI;AACtB,QAAM,UAAU,UAAU,YAAY;AACtC,QAAM,WAAW,UAAU,YAAY,QAAQ,YAAY;AAC3D,QAAM,QAAQ,QAAQ,QAAQ;AAC9B,QAAM,SAAS,QAAQ,UAAU,CAAC;AAClC,QAAM,SAAS;AACf,QAAM,MAAM;AACZ,QAAM,WAAW,wBAAwB,OAAO;AAEhD,SACE,qBAAC,SACC;AAAA,wBAAC,OAAE,WAAW,OAAO,MAAM,6HAE3B;AAAA,IAEA,qBAAC,aAAQ,cAAW,eAAc,WAAU,YAC1C;AAAA,0BAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAAK;AAAA,UACpE,KAAK,UAAU,GAAG,QAAQ,OAAO,UAAU,mBAAmB,UAAU,mBAAmB,CAAC,sBAAsB;AAAA,UAClH,MAAK;AAAA;AAAA,MACP,GACF;AAAA,MACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAAK;AAAA,UACpE,KAAK,UAAU,wBAAwB;AAAA,UACvC,MAAK;AAAA;AAAA,MACP,GACF;AAAA,MACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,oBAAC,WAAQ,OAAO,UAAU,MAAM,QAAQ,QAAQ,IAAK;AAAA,UACtE,KAAK,UAAU,eAAe;AAAA,UAC9B,MAAK;AAAA;AAAA,MACP,GACF;AAAA,MACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,GAAG,QAAQ,OAAO,UAAU,UAAU,CAAC,YAAY;AAAA,UACpE,KAAK,UAAU,GAAG,QAAQ,OAAO,UAAU,cAAc,CAAC,sCAAsC;AAAA,UAChG,MAAK;AAAA;AAAA,MACP,GACF;AAAA,OACF;AAAA,IAEC,WAAW,UAAU,cAAc,SAAS,IAC3C,iCACE;AAAA,0BAAC,QAAG,WAAW,OAAO,cAAc,2BAAa;AAAA,MACjD,oBAAC,OAAE,WAAW,OAAO,aAAa,8DAAgD;AAAA,MAClF,oBAAC,gBAAa,eAAe,UAAU,eAAe,OAAc,SAAS,mBAAgB,QAAQ,IAAI;AAAA,OAC3G,IACE;AAAA,IAEJ,oBAAC,QAAG,WAAW,OAAO,cAAc,gCAAkB;AAAA,IACrD,SAAS,UACR,oBAAC,SAAI,WAAW,OAAO,QAAQ,4IAG/B,IACE,SAAS,aAAa,CAAC,SAAS,OAClC,oBAAC,cAAW,OAAM,+BAAyB,IAE3C,iCACE;AAAA,2BAAC,SAAI,WAAW,OAAO,SACrB;AAAA,6BAAC,WAAQ,OAAM,wBACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,UAAU,OAAO,SAAS;AAAA,cAC1B,aAAY;AAAA,cACZ,OAAM;AAAA;AAAA,UACR;AAAA,UACA,oBAAC,gBAAa,OAAO,QAAQ,UAAU,OAAO,SAAS,GAAG,SAAS,UAAU,OAAM,kBAAiB;AAAA,UACpG,oBAAC,gBAAa,OAAO,QAAQ,UAAU,OAAO,SAAS,GAAG,SAAS,SAAS,OAAM,iBAAgB;AAAA,WACpG;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,UAAU,aAAa,iBAAiB,WAAW,OAAO;AAAA,YAC1D,MAAM,MAAM,iBAAiB,wBAAwB,EAAE,GAAG,SAAS,MAAM,QAAW,OAAO,OAAU,CAAC;AAAA;AAAA,QACxG;AAAA,SACF;AAAA,MAEC,SAAS,KAAK,MAAM,WAAW,IAC9B,oBAAC,aAAU,WAAW,MAAM,OAAO,+EAAiE,IAEpG;AAAA,QAAC;AAAA;AAAA,UACC,UAAU;AAAA,UACV,SAAQ;AAAA,UACR,MACE,qBAAC,QACC;AAAA,gCAAC,MAAG,kBAAI;AAAA,YACR,oBAAC,MAAG,sBAAQ;AAAA,YACZ,oBAAC,MAAG,kBAAI;AAAA,YACR,oBAAC,MAAG,iBAAG;AAAA,YACP,oBAAC,MAAG,iBAAG;AAAA,YACP,oBAAC,MAAG,oBAAM;AAAA,YACV,oBAAC,MAAG,OAAM,SAAQ,oBAAM;AAAA,aAC1B;AAAA,UAGD,mBAAS,KAAK,MAAM,IAAI,CAAC,QACxB,oBAAC,kBAA4B,OAAR,IAAI,EAAc,CACxC;AAAA;AAAA,MACH;AAAA,MAEF;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,YAAY,KAAK,IAAI,KAAK,KAAK,SAAS,KAAK,QAAQ,SAAS,GAAG,CAAC;AAAA,UAClE,OAAO,SAAS,KAAK;AAAA,UACrB,MAAK;AAAA,UACL,QAAQ;AAAA;AAAA,MACV;AAAA,OACF;AAAA,KAEJ;AAEJ;AAEA,SAAS,eAAe,EAAE,IAAI,GAAoC;AAChE,QAAM,OAAO,IAAI,UAAU,IAAI;AAE/B,SACE,qBAAC,YACC;AAAA,wBAAC,QAAG,WAAW,MAAM,KAClB,cAAI,KAAK,IAAI,EAAE,eAAe,QAAW,EAAE,KAAK,WAAW,OAAO,SAAS,MAAM,WAAW,MAAM,WAAW,QAAQ,UAAU,CAAC,GACnI;AAAA,IACA,oBAAC,QACC,+BAAC,UAAK,WAAW,OAAO,KACtB;AAAA,0BAAC,UAAK,WAAW,OAAO,SAAU,cAAI,gBAAgB,oBAAmB;AAAA,MACxE,IAAI,gBAAgB,oBAAC,UAAK,WAAW,OAAO,QAAS,cAAI,eAAc,IAAU;AAAA,OACpF,GACF;AAAA,IACA,oBAAC,QAAG,WAAW,MAAM,MAAO,cAAI,KAAK,OAAO,CAAC,EAAE,YAAY,IAAI,IAAI,KAAK,MAAM,CAAC,GAAE;AAAA,IACjF,oBAAC,QAAI,eAAK,eAAe,IAAI,OAAO,GAAE;AAAA,IACtC,oBAAC,QAAI,eAAK,2BAA2B,IAAI,MAAM,GAAE;AAAA,IACjD,oBAAC,QACC,+BAAC,UAAK,WAAW,OAAO,KACtB;AAAA,0BAAC,UAAK,WAAW,GAAG,OAAO,MAAM,IAAI,KAAK,IAAI,MAAM,CAAC,IAAK,eAAK,cAAc,IAAI,MAAM,GAAE;AAAA,MACxF,IAAI,WAAW,YAAY,IAAI,gBAAgB,oBAAC,UAAK,WAAW,OAAO,QAAS,cAAI,eAAc,IAAU;AAAA,OAC/G,GACF;AAAA,IACA,oBAAC,QAAG,WAAW,MAAM,WAAY,uBAAa,IAAI,YAAY,KAAK,GAAE;AAAA,KACvE;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/subscriptions-view.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport {\n platformAdminApi,\n type PlatformRevenue,\n type SubscriptionPaymentRow,\n} from '@withpotter/lib/admin-api';\nimport { useSubscriptionPayments } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport { CountUp, Reveal } from '../_fx';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination, StatCard, moneyIn } from '../_ui';\nimport { DownloadButton } from './download-button';\nimport {\n PURPOSE_WORDS,\n STATUS_WORDS,\n SUBSCRIPTION_METHOD_WORDS,\n downloadName,\n minorToMoney,\n word,\n} from './ledger-model';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport { PlanPaymentDrawer } from './plan-payment-drawer';\nimport { useUrlFilters } from './use-url-filters';\nimport styles from './ledger.module.css';\nimport table from './revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst FILTERS = { q: '', status: '', method: '' };\nconst integer = new Intl.NumberFormat('en-US');\n\nconst STATUSES = [\n { value: '', label: 'Every result' },\n { value: 'succeeded', label: 'Paid' },\n { value: 'failed', label: 'Failed' },\n { value: 'pending', label: 'Waiting' },\n];\nconst METHODS = [\n { value: '', label: 'Every way of paying' },\n { value: 'gateway', label: 'Card or bank' },\n { value: 'wallet', label: 'Wallet' },\n { value: 'manual', label: 'Recorded by hand' },\n];\n\nconst tone = (status: string) =>\n status === 'succeeded' ? styles.statusGood : status === 'failed' ? styles.statusBad : styles.statusWait;\n\n/** What merchants pay Olive for their plan: the income figures, who pays what, and every plan payment. */\nexport function SubscriptionsView({\n startDate,\n endDate,\n revenue,\n}: {\n startDate?: string;\n endDate?: string;\n revenue: PlatformRevenue;\n}) {\n const { values, page, set } = useUrlFilters('sp', FILTERS);\n const [search, setSearch] = useState(values.q);\n const [open, setOpen] = useState<SubscriptionPaymentRow | null>(null);\n\n useEffect(() => {\n const timer = setTimeout(() => {\n if (search.trim() !== values.q) set({ q: search.trim() });\n }, 300);\n\n return () => clearTimeout(timer);\n // Only a change the person typed should write to the address.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [search]);\n\n const filters = useMemo(\n () => ({ startDate, endDate, status: values.status, method: values.method, q: values.q, page, limit: PAGE_SIZE }),\n [startDate, endDate, values, page],\n );\n\n const { recurring } = revenue;\n const tracked = recurring.tracked !== false;\n const currency = recurring.currency ?? revenue.currency ?? 'NGN';\n const money = moneyIn(currency);\n const money2 = moneyIn(currency, 2);\n const notSet = 'Not set up yet';\n const why = 'The plan payments have not been connected to this page yet.';\n const payments = useSubscriptionPayments(filters);\n\n return (\n <div>\n <p className={styles.lede}>\n This is money that merchants pay Olive for their plan. It is kept apart from the money customers pay merchants.\n </p>\n\n <section aria-label=\"Plan income\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={tracked ? <CountUp value={recurring.mrr} format={money} /> : notSet}\n sub={tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : why}\n hint=\"What merchants on a paid plan are expected to pay Olive every month. Merchants on a free plan, a trial or a pause are not counted. (MRR)\"\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={tracked ? <CountUp value={recurring.arr} format={money} /> : notSet}\n sub={tracked ? 'Each month times 12' : why}\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12. (ARR)\"\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={tracked ? <CountUp value={recurring.arpa} format={money2} /> : notSet}\n sub={tracked ? 'Each month' : why}\n hint=\"What one paying merchant pays Olive in a month, on average. (ARPA)\"\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Joined and left\"\n value={tracked ? `${integer.format(recurring.newInRange)} joined` : notSet}\n sub={tracked ? `${integer.format(recurring.churnedInRange)} left or cancelled in this period` : why}\n hint=\"Merchants who started a plan in this period, and merchants whose plan was cancelled, ended or switched off.\"\n />\n </Reveal>\n </section>\n\n {tracked && recurring.planBreakdown.length > 0 ? (\n <>\n <h2 className={styles.sectionTitle}>Who pays what</h2>\n <p className={styles.sectionNote}>How the monthly plan income splits across plans.</p>\n <PlanMixPanel planBreakdown={recurring.planBreakdown} money={money} caption={`Each month · ${currency}`} />\n </>\n ) : null}\n\n <h2 className={styles.sectionTitle}>Every plan payment</h2>\n {payments.isError ? (\n <div className={styles.notice}>\n Plan payments are not available on this box yet. Once the subscription module is connected, every payment will\n be listed here.\n </div>\n ) : payments.isLoading || !payments.data ? (\n <AdmLoading label=\"Loading plan payments…\" />\n ) : (\n <>\n <div className={styles.toolbar}>\n <Filters label=\"Filter plan payments\">\n <FilterSearch\n value={search}\n onChange={setSearch}\n placeholder=\"Search a merchant's name or email, or a reference\"\n label=\"Search plan payments\"\n />\n <FilterSelect value={values.status} onChange={(status) => set({ status })} options={STATUSES} label=\"Payment result\" />\n <FilterSelect value={values.method} onChange={(method) => set({ method })} options={METHODS} label=\"Way of paying\" />\n </Filters>\n <DownloadButton\n filename={downloadName('plan-payments', startDate, endDate)}\n load={() => platformAdminApi.subscriptionPaymentsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {payments.data.items.length === 0 ? (\n <EmptyNote className={table.empty}>No plan payments match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={860}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Merchant</Th>\n <Th>Plan</Th>\n <Th>For</Th>\n <Th>How</Th>\n <Th>Result</Th>\n <Th align=\"right\">Amount</Th>\n </tr>\n }\n >\n {payments.data.items.map((row) => (\n <PlanPaymentRow key={row.id} row={row} onOpen={setOpen} />\n ))}\n </TableShell>\n )}\n <Pagination\n page={page}\n totalPages={Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1)}\n total={payments.data.total}\n noun=\"plan payments\"\n onPage={(next) => set({ page: next })}\n />\n <PlanPaymentDrawer row={open} onClose={() => setOpen(null)} />\n </>\n )}\n </div>\n );\n}\n\nfunction PlanPaymentRow({ row, onOpen }: { row: SubscriptionPaymentRow; onOpen: (row: SubscriptionPaymentRow) => void }) {\n const when = row.paidAt ?? row.createdAt;\n\n return (\n <TableRow onClick={() => onOpen(row)}>\n <td className={table.num}>\n {new Date(when).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span className={styles.who}>\n <span className={styles.whoName}>{row.merchantName || 'Unknown merchant'}</span>\n {row.merchantEmail ? <span className={styles.whoSub}>{row.merchantEmail}</span> : null}\n </span>\n </td>\n <td className={table.name}>{row.plan.charAt(0).toUpperCase() + row.plan.slice(1)}</td>\n <td>{word(PURPOSE_WORDS, row.purpose)}</td>\n <td>{word(SUBSCRIPTION_METHOD_WORDS, row.method)}</td>\n <td>\n <span className={styles.who}>\n <span className={`${styles.status} ${tone(row.status)}`}>{word(STATUS_WORDS, row.status)}</span>\n {row.status === 'failed' && row.failureReason ? <span className={styles.whoSub}>{row.failureReason}</span> : null}\n </span>\n </td>\n <td className={table.numStrong}>{minorToMoney(row.amountKobo, 'NGN')}</td>\n </TableRow>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,34 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { toCsv } from "@withpotter/lib/csv";
4
+ import { DownloadButton } from "./download-button";
5
+ import { downloadName } from "./ledger-model";
6
+ import { PrintSummaryButton } from "./print-summary-button";
7
+ import { SUMMARY_HEAD, summaryRows } from "./summary-rows";
8
+ import { summaryModel } from "./summary-print";
9
+ function SummaryDownload({
10
+ ledger,
11
+ revenue,
12
+ currency,
13
+ startDate,
14
+ endDate,
15
+ period
16
+ }) {
17
+ const rows = summaryRows(ledger, revenue, currency);
18
+ if (rows.length === 0) return null;
19
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
20
+ /* @__PURE__ */ jsx(PrintSummaryButton, { model: summaryModel(ledger, revenue, currency, period) }),
21
+ /* @__PURE__ */ jsx(
22
+ DownloadButton,
23
+ {
24
+ label: "Download summary",
25
+ filename: downloadName("revenue-summary", startDate, endDate),
26
+ load: async () => toCsv([SUMMARY_HEAD, ...rows])
27
+ }
28
+ )
29
+ ] });
30
+ }
31
+ export {
32
+ SummaryDownload
33
+ };
34
+ //# sourceMappingURL=summary-download.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/revenue/summary-download.tsx"],"sourcesContent":["'use client';\n\nimport { toCsv } from '@withpotter/lib/csv';\nimport type { MerchantPaymentsPage, PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { DownloadButton } from './download-button';\nimport { downloadName } from './ledger-model';\nimport { PrintSummaryButton } from './print-summary-button';\nimport { SUMMARY_HEAD, summaryRows } from './summary-rows';\nimport { summaryModel } from './summary-print';\n\n/** The figures on the Overview as a one-page PDF (through the print window) or a small spreadsheet. */\nexport function SummaryDownload({\n ledger,\n revenue,\n currency,\n startDate,\n endDate,\n period,\n}: {\n ledger?: MerchantPaymentsPage;\n revenue?: PlatformRevenue;\n currency: string;\n startDate?: string;\n endDate?: string;\n /** The period in words, such as \"last 30 days\". */\n period: string;\n}) {\n const rows = summaryRows(ledger, revenue, currency);\n\n if (rows.length === 0) return null;\n\n return (\n <>\n <PrintSummaryButton model={summaryModel(ledger, revenue, currency, period)} />\n <DownloadButton\n label=\"Download summary\"\n filename={downloadName('revenue-summary', startDate, endDate)}\n load={async () => toCsv([SUMMARY_HEAD, ...rows])}\n />\n </>\n );\n}\n"],"mappings":";AAgCI,mBACE,KADF;AA9BJ,SAAS,aAAa;AAEtB,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B;AACnC,SAAS,cAAc,mBAAmB;AAC1C,SAAS,oBAAoB;AAGtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAQG;AACD,QAAM,OAAO,YAAY,QAAQ,SAAS,QAAQ;AAElD,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,SACE,iCACE;AAAA,wBAAC,sBAAmB,OAAO,aAAa,QAAQ,SAAS,UAAU,MAAM,GAAG;AAAA,IAC5E;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,UAAU,aAAa,mBAAmB,WAAW,OAAO;AAAA,QAC5D,MAAM,YAAY,MAAM,CAAC,cAAc,GAAG,IAAI,CAAC;AAAA;AAAA,IACjD;AAAA,KACF;AAEJ;","names":[]}
@@ -0,0 +1,82 @@
1
+ import { methodCards, minorToMoney, pctChange } from "./ledger-model";
2
+ const integer = new Intl.NumberFormat("en-US");
3
+ const change = (now, before) => {
4
+ const pct = pctChange(now, before);
5
+ return pct === null ? void 0 : `${pct > 0 ? "+" : ""}${pct}% since the period before`;
6
+ };
7
+ function summaryModel(ledger, revenue, currency, period) {
8
+ const groups = [];
9
+ const now = ledger?.cards.current.find((c) => c.currency === currency);
10
+ const before = ledger?.cards.previous?.find((c) => c.currency === currency);
11
+ const money = (minor) => minorToMoney(minor, currency);
12
+ if (ledger && now) {
13
+ groups.push({
14
+ heading: "Money merchants earned",
15
+ note: "What customers paid to merchants. This is the merchants' money, not Olive's own income.",
16
+ rows: [
17
+ { label: "Money received", value: money(now.grossMinor), detail: change(now.grossMinor, before?.grossMinor) },
18
+ { label: "Fees taken", value: money(now.feeMinor), detail: change(now.feeMinor, before?.feeMinor) },
19
+ { label: "Merchants keep", value: money(now.netMinor), detail: change(now.netMinor, before?.netMinor) },
20
+ { label: "Refunded", value: money(now.refundsMinor) },
21
+ { label: "Payments that went through", value: integer.format(now.payments) },
22
+ { label: "Payments that worked", value: now.successRate === null ? "\u2013" : `${now.successRate}%` }
23
+ ]
24
+ });
25
+ groups.push({
26
+ heading: "How customers paid",
27
+ note: "Money received, by way of paying.",
28
+ rows: methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency).map((card) => ({
29
+ label: card.label,
30
+ value: money(card.amountMinor),
31
+ detail: card.payments === 0 ? "No payments this way" : [
32
+ `${integer.format(card.payments)} payments`,
33
+ `${card.share}% of the money received`,
34
+ card.change === null ? "" : `${card.change > 0 ? "+" : ""}${card.change}% since the period before`
35
+ ].filter(Boolean).join(" \xB7 ")
36
+ }))
37
+ });
38
+ }
39
+ const plan = revenue?.recurring;
40
+ const naira = (value) => minorToMoney(Math.round(value * 100), plan?.currency ?? "NGN");
41
+ groups.push({
42
+ heading: "Money Olive earns",
43
+ note: "What merchants pay Olive for their plan.",
44
+ rows: plan && plan.tracked !== false ? [
45
+ { label: "Plan income each month", value: naira(plan.mrr) },
46
+ { label: "Plan income over a year", value: naira(plan.arr) },
47
+ { label: "Average per paying merchant", value: naira(plan.arpa) },
48
+ { label: "Merchants paying", value: integer.format(plan.payingMerchants ?? plan.activeSubscriptions) },
49
+ { label: "Joined this period", value: integer.format(plan.newInRange) },
50
+ { label: "Left this period", value: integer.format(plan.churnedInRange) }
51
+ ] : [{ label: "Plan income", value: "Not set up yet", detail: "The plan payments are not connected on this box." }]
52
+ });
53
+ return { title: "Revenue summary", period, groups };
54
+ }
55
+ const esc = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
56
+ function summaryHtml(model, brand, printedOn) {
57
+ const groups = model.groups.map(
58
+ (group) => `<section><h2>${esc(group.heading)}</h2><p class="note">${esc(group.note)}</p><table>${group.rows.map(
59
+ (row) => `<tr><td class="label">${esc(row.label)}${row.detail ? `<div class="detail">${esc(row.detail)}</div>` : ""}</td><td class="value">${esc(row.value)}</td></tr>`
60
+ ).join("")}</table></section>`
61
+ ).join("");
62
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(model.title)}</title><style>
63
+ @page { size: A4; margin: 14mm; }
64
+ * { box-sizing: border-box; }
65
+ body { margin: 0; font: 12px/1.45 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: #1f2a14; }
66
+ h1 { margin: 0 0 2px; font-size: 22px; color: #3d5a1e; }
67
+ .period { margin: 0 0 14px; color: #5b6356; font-size: 13px; }
68
+ section { margin: 0 0 14px; break-inside: avoid; }
69
+ h2 { margin: 0 0 2px; font-size: 14px; color: #3d5a1e; }
70
+ .note { margin: 0 0 6px; color: #5b6356; }
71
+ table { width: 100%; border-collapse: collapse; }
72
+ td { padding: 5px 0; border-bottom: 1px solid #d9ddd3; vertical-align: top; }
73
+ .value { text-align: right; font-weight: 600; white-space: nowrap; padding-left: 12px; }
74
+ .detail { color: #5b6356; font-size: 11px; }
75
+ footer { margin-top: 10px; color: #5b6356; font-size: 10px; }
76
+ </style></head><body><h1>${esc(model.title)}</h1><p class="period">${esc(brand)} \xB7 ${esc(model.period)}</p>${groups}<footer>Printed ${esc(printedOn)}. Amounts are in the currency shown; different currencies are never added together.</footer></body></html>`;
77
+ }
78
+ export {
79
+ summaryHtml,
80
+ summaryModel
81
+ };
82
+ //# sourceMappingURL=summary-print.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/revenue/summary-print.ts"],"sourcesContent":["import type { MerchantPaymentsPage, PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { methodCards, minorToMoney, pctChange } from './ledger-model';\n\nexport interface SummaryRow {\n label: string;\n value: string;\n /** A short second line: the change since the period before, or a count. */\n detail?: string;\n}\n\nexport interface SummaryGroup {\n heading: string;\n note: string;\n rows: SummaryRow[];\n}\n\nexport interface SummaryModel {\n title: string;\n period: string;\n groups: SummaryGroup[];\n}\n\nconst integer = new Intl.NumberFormat('en-US');\n\nconst change = (now: string | number, before?: string | number): string | undefined => {\n const pct = pctChange(now, before);\n\n return pct === null ? undefined : `${pct > 0 ? '+' : ''}${pct}% since the period before`;\n};\n\n/** The Overview's figures as words and amounts a person can read on one printed page. */\nexport function summaryModel(\n ledger: MerchantPaymentsPage | undefined,\n revenue: PlatformRevenue | undefined,\n currency: string,\n period: string,\n): SummaryModel {\n const groups: SummaryGroup[] = [];\n const now = ledger?.cards.current.find((c) => c.currency === currency);\n const before = ledger?.cards.previous?.find((c) => c.currency === currency);\n const money = (minor: string) => minorToMoney(minor, currency);\n\n if (ledger && now) {\n groups.push({\n heading: 'Money merchants earned',\n note: \"What customers paid to merchants. This is the merchants' money, not Olive's own income.\",\n rows: [\n { label: 'Money received', value: money(now.grossMinor), detail: change(now.grossMinor, before?.grossMinor) },\n { label: 'Fees taken', value: money(now.feeMinor), detail: change(now.feeMinor, before?.feeMinor) },\n { label: 'Merchants keep', value: money(now.netMinor), detail: change(now.netMinor, before?.netMinor) },\n { label: 'Refunded', value: money(now.refundsMinor) },\n { label: 'Payments that went through', value: integer.format(now.payments) },\n { label: 'Payments that worked', value: now.successRate === null ? '–' : `${now.successRate}%` },\n ],\n });\n\n groups.push({\n heading: 'How customers paid',\n note: 'Money received, by way of paying.',\n rows: methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency).map((card) => ({\n label: card.label,\n value: money(card.amountMinor),\n detail:\n card.payments === 0\n ? 'No payments this way'\n : [\n `${integer.format(card.payments)} payments`,\n `${card.share}% of the money received`,\n card.change === null ? '' : `${card.change > 0 ? '+' : ''}${card.change}% since the period before`,\n ]\n .filter(Boolean)\n .join(' · '),\n })),\n });\n }\n\n const plan = revenue?.recurring;\n const naira = (value: number) => minorToMoney(Math.round(value * 100), plan?.currency ?? 'NGN');\n\n groups.push({\n heading: 'Money Olive earns',\n note: 'What merchants pay Olive for their plan.',\n rows:\n plan && plan.tracked !== false\n ? [\n { label: 'Plan income each month', value: naira(plan.mrr) },\n { label: 'Plan income over a year', value: naira(plan.arr) },\n { label: 'Average per paying merchant', value: naira(plan.arpa) },\n { label: 'Merchants paying', value: integer.format(plan.payingMerchants ?? plan.activeSubscriptions) },\n { label: 'Joined this period', value: integer.format(plan.newInRange) },\n { label: 'Left this period', value: integer.format(plan.churnedInRange) },\n ]\n : [{ label: 'Plan income', value: 'Not set up yet', detail: 'The plan payments are not connected on this box.' }],\n });\n\n return { title: 'Revenue summary', period, groups };\n}\n\nconst esc = (text: string): string =>\n text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n\n/** A self-contained page that prints on one A4 sheet. Every value is escaped. */\nexport function summaryHtml(model: SummaryModel, brand: string, printedOn: string): string {\n const groups = model.groups\n .map(\n (group) => `<section><h2>${esc(group.heading)}</h2><p class=\"note\">${esc(group.note)}</p><table>${group.rows\n .map(\n (row) =>\n `<tr><td class=\"label\">${esc(row.label)}${row.detail ? `<div class=\"detail\">${esc(row.detail)}</div>` : ''}</td><td class=\"value\">${esc(row.value)}</td></tr>`,\n )\n .join('')}</table></section>`,\n )\n .join('');\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>${esc(model.title)}</title><style>\n@page { size: A4; margin: 14mm; }\n* { box-sizing: border-box; }\nbody { margin: 0; font: 12px/1.45 -apple-system, \"Segoe UI\", Helvetica, Arial, sans-serif; color: #1f2a14; }\nh1 { margin: 0 0 2px; font-size: 22px; color: #3d5a1e; }\n.period { margin: 0 0 14px; color: #5b6356; font-size: 13px; }\nsection { margin: 0 0 14px; break-inside: avoid; }\nh2 { margin: 0 0 2px; font-size: 14px; color: #3d5a1e; }\n.note { margin: 0 0 6px; color: #5b6356; }\ntable { width: 100%; border-collapse: collapse; }\ntd { padding: 5px 0; border-bottom: 1px solid #d9ddd3; vertical-align: top; }\n.value { text-align: right; font-weight: 600; white-space: nowrap; padding-left: 12px; }\n.detail { color: #5b6356; font-size: 11px; }\nfooter { margin-top: 10px; color: #5b6356; font-size: 10px; }\n</style></head><body><h1>${esc(model.title)}</h1><p class=\"period\">${esc(brand)} · ${esc(model.period)}</p>${groups}<footer>Printed ${esc(printedOn)}. Amounts are in the currency shown; different currencies are never added together.</footer></body></html>`;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,44 @@
1
+ import { methodCards, pctChange, plainAmount } from "./ledger-model";
2
+ const SUMMARY_HEAD = ["Section", "Figure", "This period", "Period before", "Change (%)"];
3
+ const naira = (kobo) => plainAmount(Math.round(kobo * 100));
4
+ function summaryRows(ledger, revenue, currency) {
5
+ const rows = [];
6
+ const now = ledger?.cards.current.find((c) => c.currency === currency);
7
+ const before = ledger?.cards.previous?.find((c) => c.currency === currency);
8
+ if (now) {
9
+ const money = (label, a, b) => rows.push(["Merchant payments", label, plainAmount(a), b === void 0 ? "" : plainAmount(b), String(pctChange(a, b) ?? "")]);
10
+ money("Money received", now.grossMinor, before?.grossMinor);
11
+ money("Fees taken", now.feeMinor, before?.feeMinor);
12
+ money("Merchants keep", now.netMinor, before?.netMinor);
13
+ money("Refunded", now.refundsMinor, before?.refundsMinor);
14
+ rows.push(["Merchant payments", "Payments that went through", String(now.payments), before ? String(before.payments) : "", String(pctChange(now.payments, before?.payments) ?? "")]);
15
+ rows.push(["Merchant payments", "Payments that worked (%)", String(now.successRate ?? ""), String(before?.successRate ?? ""), ""]);
16
+ }
17
+ if (ledger) {
18
+ for (const card of methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency)) {
19
+ const prior = ledger.byChannel.previous?.find((r) => r.currency === currency && r.channel.key === card.key);
20
+ rows.push([
21
+ "How customers paid",
22
+ card.label,
23
+ plainAmount(card.amountMinor),
24
+ ledger.byChannel.previous ? plainAmount(prior?.amountMinor ?? "0") : "",
25
+ String(card.change ?? "")
26
+ ]);
27
+ }
28
+ }
29
+ const plan = revenue?.recurring;
30
+ if (plan && plan.tracked !== false) {
31
+ rows.push(["Plan income", "Income each month", naira(plan.mrr), "", ""]);
32
+ rows.push(["Plan income", "Income over a year", naira(plan.arr), "", ""]);
33
+ rows.push(["Plan income", "Average per paying merchant", naira(plan.arpa), "", ""]);
34
+ rows.push(["Plan income", "Merchants paying", String(plan.payingMerchants ?? plan.activeSubscriptions), "", ""]);
35
+ rows.push(["Plan income", "Joined in this period", String(plan.newInRange), "", ""]);
36
+ rows.push(["Plan income", "Left in this period", String(plan.churnedInRange), "", ""]);
37
+ }
38
+ return rows;
39
+ }
40
+ export {
41
+ SUMMARY_HEAD,
42
+ summaryRows
43
+ };
44
+ //# sourceMappingURL=summary-rows.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/revenue/summary-rows.ts"],"sourcesContent":["import type { MerchantPaymentsPage, PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { methodCards, pctChange, plainAmount } from './ledger-model';\n\nexport const SUMMARY_HEAD = ['Section', 'Figure', 'This period', 'Period before', 'Change (%)'];\n\nconst naira = (kobo: number): string => plainAmount(Math.round(kobo * 100));\n\n/**\n * The Revenue figures as rows for a spreadsheet: what customers paid merchants (with the period before), the ways\n * of paying, and Olive's plan income. Amounts are plain numbers in one currency.\n */\nexport function summaryRows(\n ledger: MerchantPaymentsPage | undefined,\n revenue: PlatformRevenue | undefined,\n currency: string,\n): string[][] {\n const rows: string[][] = [];\n const now = ledger?.cards.current.find((c) => c.currency === currency);\n const before = ledger?.cards.previous?.find((c) => c.currency === currency);\n\n if (now) {\n const money = (label: string, a: string, b?: string) =>\n rows.push(['Merchant payments', label, plainAmount(a), b === undefined ? '' : plainAmount(b), String(pctChange(a, b) ?? '')]);\n\n money('Money received', now.grossMinor, before?.grossMinor);\n money('Fees taken', now.feeMinor, before?.feeMinor);\n money('Merchants keep', now.netMinor, before?.netMinor);\n money('Refunded', now.refundsMinor, before?.refundsMinor);\n rows.push(['Merchant payments', 'Payments that went through', String(now.payments), before ? String(before.payments) : '', String(pctChange(now.payments, before?.payments) ?? '')]);\n rows.push(['Merchant payments', 'Payments that worked (%)', String(now.successRate ?? ''), String(before?.successRate ?? ''), '']);\n }\n\n if (ledger) {\n for (const card of methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency)) {\n const prior = ledger.byChannel.previous?.find((r) => r.currency === currency && r.channel.key === card.key);\n\n rows.push([\n 'How customers paid',\n card.label,\n plainAmount(card.amountMinor),\n ledger.byChannel.previous ? plainAmount(prior?.amountMinor ?? '0') : '',\n String(card.change ?? ''),\n ]);\n }\n }\n\n const plan = revenue?.recurring;\n\n if (plan && plan.tracked !== false) {\n rows.push(['Plan income', 'Income each month', naira(plan.mrr), '', '']);\n rows.push(['Plan income', 'Income over a year', naira(plan.arr), '', '']);\n rows.push(['Plan income', 'Average per paying merchant', naira(plan.arpa), '', '']);\n rows.push(['Plan income', 'Merchants paying', String(plan.payingMerchants ?? plan.activeSubscriptions), '', '']);\n rows.push(['Plan income', 'Joined in this period', String(plan.newInRange), '', '']);\n rows.push(['Plan income', 'Left in this period', String(plan.churnedInRange), '', '']);\n }\n\n return rows;\n}\n"],"mappings":"AACA,SAAS,aAAa,WAAW,mBAAmB;AAE7C,MAAM,eAAe,CAAC,WAAW,UAAU,eAAe,iBAAiB,YAAY;AAE9F,MAAM,QAAQ,CAAC,SAAyB,YAAY,KAAK,MAAM,OAAO,GAAG,CAAC;AAMnE,SAAS,YACd,QACA,SACA,UACY;AACZ,QAAM,OAAmB,CAAC;AAC1B,QAAM,MAAM,QAAQ,MAAM,QAAQ,KAAK,CAAC,MAAM,EAAE,aAAa,QAAQ;AACrE,QAAM,SAAS,QAAQ,MAAM,UAAU,KAAK,CAAC,MAAM,EAAE,aAAa,QAAQ;AAE1E,MAAI,KAAK;AACP,UAAM,QAAQ,CAAC,OAAe,GAAW,MACvC,KAAK,KAAK,CAAC,qBAAqB,OAAO,YAAY,CAAC,GAAG,MAAM,SAAY,KAAK,YAAY,CAAC,GAAG,OAAO,UAAU,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC;AAE9H,UAAM,kBAAkB,IAAI,YAAY,QAAQ,UAAU;AAC1D,UAAM,cAAc,IAAI,UAAU,QAAQ,QAAQ;AAClD,UAAM,kBAAkB,IAAI,UAAU,QAAQ,QAAQ;AACtD,UAAM,YAAY,IAAI,cAAc,QAAQ,YAAY;AACxD,SAAK,KAAK,CAAC,qBAAqB,8BAA8B,OAAO,IAAI,QAAQ,GAAG,SAAS,OAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,UAAU,IAAI,UAAU,QAAQ,QAAQ,KAAK,EAAE,CAAC,CAAC;AACnL,SAAK,KAAK,CAAC,qBAAqB,4BAA4B,OAAO,IAAI,eAAe,EAAE,GAAG,OAAO,QAAQ,eAAe,EAAE,GAAG,EAAE,CAAC;AAAA,EACnI;AAEA,MAAI,QAAQ;AACV,eAAW,QAAQ,YAAY,OAAO,UAAU,SAAS,OAAO,UAAU,UAAU,QAAQ,GAAG;AAC7F,YAAM,QAAQ,OAAO,UAAU,UAAU,KAAK,CAAC,MAAM,EAAE,aAAa,YAAY,EAAE,QAAQ,QAAQ,KAAK,GAAG;AAE1G,WAAK,KAAK;AAAA,QACR;AAAA,QACA,KAAK;AAAA,QACL,YAAY,KAAK,WAAW;AAAA,QAC5B,OAAO,UAAU,WAAW,YAAY,OAAO,eAAe,GAAG,IAAI;AAAA,QACrE,OAAO,KAAK,UAAU,EAAE;AAAA,MAC1B,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,OAAO,SAAS;AAEtB,MAAI,QAAQ,KAAK,YAAY,OAAO;AAClC,SAAK,KAAK,CAAC,eAAe,qBAAqB,MAAM,KAAK,GAAG,GAAG,IAAI,EAAE,CAAC;AACvE,SAAK,KAAK,CAAC,eAAe,sBAAsB,MAAM,KAAK,GAAG,GAAG,IAAI,EAAE,CAAC;AACxE,SAAK,KAAK,CAAC,eAAe,+BAA+B,MAAM,KAAK,IAAI,GAAG,IAAI,EAAE,CAAC;AAClF,SAAK,KAAK,CAAC,eAAe,oBAAoB,OAAO,KAAK,mBAAmB,KAAK,mBAAmB,GAAG,IAAI,EAAE,CAAC;AAC/G,SAAK,KAAK,CAAC,eAAe,yBAAyB,OAAO,KAAK,UAAU,GAAG,IAAI,EAAE,CAAC;AACnF,SAAK,KAAK,CAAC,eAAe,uBAAuB,OAAO,KAAK,cAAc,GAAG,IAAI,EAAE,CAAC;AAAA,EACvF;AAEA,SAAO;AACT;","names":[]}
@@ -17,7 +17,7 @@ function TopTenantsPanel({
17
17
  return /* @__PURE__ */ jsx(
18
18
  Panel,
19
19
  {
20
- title: "Top merchants by GMV",
20
+ title: "Biggest merchants by sales",
21
21
  count: tenants.length || void 0,
22
22
  caption: focus ?? void 0,
23
23
  action: /* @__PURE__ */ jsx(Link, { href: "/admin/tenants", children: "View all" }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/top-tenants-panel.tsx"],"sourcesContent":["import Link from 'next/link';\nimport { useRouter } from 'next/navigation';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { EntityAvatar } from '../_fx';\nimport { PICK_CURRENCY, Panel, moneyIn } from '../_ui';\nimport { logoOf } from '../tenants/tenants-logo';\nimport styles from './revenue.module.css';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * Merchants ranked by GMV within one currency, with order count, AOV and\n * share of that currency's lifetime GMV.\n */\nexport function TopTenantsPanel({\n tenants,\n lifetime,\n focus,\n}: {\n tenants: PlatformRevenue['gmv']['topTenants'];\n /** Lifetime GMV in `focus`, the base for each share. */\n lifetime: number;\n /** The one currency ranked, or null when there are several and none is chosen. */\n focus: string | null;\n}) {\n const router = useRouter();\n const money = moneyIn(focus ?? '');\n\n return (\n <Panel\n title=\"Top merchants by GMV\"\n count={tenants.length || undefined}\n caption={focus ?? undefined}\n action={<Link href=\"/admin/tenants\">View all</Link>}\n flush\n >\n {tenants.length === 0 ? (\n <EmptyNote className={styles.empty}>\n {focus ? `No ${focus} merchant revenue recorded yet.` : PICK_CURRENCY}\n </EmptyNote>\n ) : (\n <TableShell\n bare\n minWidth={520}\n density=\"compact\"\n head={\n <tr>\n <Th>Merchant</Th>\n <Th align=\"right\">Orders</Th>\n <Th align=\"right\">AOV</Th>\n <Th align=\"right\">GMV</Th>\n <Th align=\"right\">% of GMV</Th>\n </tr>\n }\n >\n {tenants.map((t) => {\n const aov = t.orders > 0 ? t.gmv / t.orders : 0;\n const share = lifetime > 0 ? (t.gmv / lifetime) * 100 : 0;\n const logo = logoOf(t);\n return (\n <TableRow\n key={t.id}\n onClick={() => router.push(`/admin/tenants/${t.id}`)}\n >\n <td className={styles.name}>\n <span className={styles.tenant}>\n {logo && (\n <EntityAvatar\n name={t.businessName}\n src={logo}\n size={28}\n />\n )}\n <span className={styles.tenantText}>\n <Link\n href={`/admin/tenants/${t.id}`}\n title={t.businessName}\n onClick={(e) => e.stopPropagation()}\n >\n {t.businessName}\n </Link>\n {t.slug && (\n <span className={styles.slug} title={t.slug}>\n {t.slug}\n </span>\n )}\n </span>\n </span>\n </td>\n <td className={styles.num}>{integer.format(t.orders)}</td>\n <td className={styles.num}>{money(aov)}</td>\n <td className={styles.numStrong}>{money(t.gmv)}</td>\n <td className={styles.num}>\n <span className={styles.share}>\n <span className={styles.shareTrack} aria-hidden>\n <span\n className={styles.shareFill}\n style={{ width: `${Math.min(share, 100)}%` }}\n />\n </span>\n {share.toFixed(1)}%\n </span>\n </td>\n </TableRow>\n );\n })}\n </TableShell>\n )}\n </Panel>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/top-tenants-panel.tsx"],"sourcesContent":["import Link from 'next/link';\nimport { useRouter } from 'next/navigation';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { EntityAvatar } from '../_fx';\nimport { PICK_CURRENCY, Panel, moneyIn } from '../_ui';\nimport { logoOf } from '../tenants/tenants-logo';\nimport styles from './revenue.module.css';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * Merchants ranked by GMV within one currency, with order count, AOV and\n * share of that currency's lifetime GMV.\n */\nexport function TopTenantsPanel({\n tenants,\n lifetime,\n focus,\n}: {\n tenants: PlatformRevenue['gmv']['topTenants'];\n /** Lifetime GMV in `focus`, the base for each share. */\n lifetime: number;\n /** The one currency ranked, or null when there are several and none is chosen. */\n focus: string | null;\n}) {\n const router = useRouter();\n const money = moneyIn(focus ?? '');\n\n return (\n <Panel\n title=\"Biggest merchants by sales\"\n count={tenants.length || undefined}\n caption={focus ?? undefined}\n action={<Link href=\"/admin/tenants\">View all</Link>}\n flush\n >\n {tenants.length === 0 ? (\n <EmptyNote className={styles.empty}>\n {focus ? `No ${focus} merchant revenue recorded yet.` : PICK_CURRENCY}\n </EmptyNote>\n ) : (\n <TableShell\n bare\n minWidth={520}\n density=\"compact\"\n head={\n <tr>\n <Th>Merchant</Th>\n <Th align=\"right\">Orders</Th>\n <Th align=\"right\">AOV</Th>\n <Th align=\"right\">GMV</Th>\n <Th align=\"right\">% of GMV</Th>\n </tr>\n }\n >\n {tenants.map((t) => {\n const aov = t.orders > 0 ? t.gmv / t.orders : 0;\n const share = lifetime > 0 ? (t.gmv / lifetime) * 100 : 0;\n const logo = logoOf(t);\n return (\n <TableRow\n key={t.id}\n onClick={() => router.push(`/admin/tenants/${t.id}`)}\n >\n <td className={styles.name}>\n <span className={styles.tenant}>\n {logo && (\n <EntityAvatar\n name={t.businessName}\n src={logo}\n size={28}\n />\n )}\n <span className={styles.tenantText}>\n <Link\n href={`/admin/tenants/${t.id}`}\n title={t.businessName}\n onClick={(e) => e.stopPropagation()}\n >\n {t.businessName}\n </Link>\n {t.slug && (\n <span className={styles.slug} title={t.slug}>\n {t.slug}\n </span>\n )}\n </span>\n </span>\n </td>\n <td className={styles.num}>{integer.format(t.orders)}</td>\n <td className={styles.num}>{money(aov)}</td>\n <td className={styles.numStrong}>{money(t.gmv)}</td>\n <td className={styles.num}>\n <span className={styles.share}>\n <span className={styles.shareTrack} aria-hidden>\n <span\n className={styles.shareFill}\n style={{ width: `${Math.min(share, 100)}%` }}\n />\n </span>\n {share.toFixed(1)}%\n </span>\n </td>\n </TableRow>\n );\n })}\n </TableShell>\n )}\n </Panel>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,36 @@
1
+ const key = (prefix, name) => `${prefix}_${name}`;
2
+ function readFilters(search, prefix, defaults) {
3
+ const params = new URLSearchParams(search);
4
+ const out = {};
5
+ for (const name of Object.keys(defaults)) {
6
+ out[name] = params.get(key(prefix, name)) ?? defaults[name];
7
+ }
8
+ return out;
9
+ }
10
+ function readPage(search, prefix) {
11
+ const page = Number.parseInt(new URLSearchParams(search).get(key(prefix, "page")) ?? "", 10);
12
+ return Number.isFinite(page) && page > 0 ? page : 1;
13
+ }
14
+ function applyFilters(search, prefix, patch, defaults) {
15
+ const params = new URLSearchParams(search);
16
+ const changesPage = "page" in patch;
17
+ for (const [name, value] of Object.entries(patch)) {
18
+ const text = String(value);
19
+ const unchanged = name === "page" ? text === "1" : text === (defaults[name] ?? "");
20
+ if (unchanged) {
21
+ params.delete(key(prefix, name));
22
+ } else {
23
+ params.set(key(prefix, name), text);
24
+ }
25
+ }
26
+ if (!changesPage) {
27
+ params.delete(key(prefix, "page"));
28
+ }
29
+ return params.toString();
30
+ }
31
+ export {
32
+ applyFilters,
33
+ readFilters,
34
+ readPage
35
+ };
36
+ //# sourceMappingURL=url-filters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/revenue/url-filters.ts"],"sourcesContent":["/**\n * Filters that live in the page address, so a view can be bookmarked or sent to someone. Each view has its own\n * prefix (`mp_`, `sp_`, `mr_`), so a filter of one view never leaks into another when the tab changes.\n */\n\n/** One filter's name in the address. */\nconst key = (prefix: string, name: string): string => `${prefix}_${name}`;\n\n/** The values of a view's filters, read from the address; a missing one is its default. */\nexport function readFilters<T extends Record<string, string>>(\n search: string,\n prefix: string,\n defaults: T,\n): T {\n const params = new URLSearchParams(search);\n const out: Record<string, string> = {};\n\n for (const name of Object.keys(defaults)) {\n out[name] = params.get(key(prefix, name)) ?? defaults[name];\n }\n\n return out as T;\n}\n\n/** The page number, at least 1. */\nexport function readPage(search: string, prefix: string): number {\n const page = Number.parseInt(new URLSearchParams(search).get(key(prefix, 'page')) ?? '', 10);\n\n return Number.isFinite(page) && page > 0 ? page : 1;\n}\n\n/**\n * The address after a change. A filter set back to its default is removed, so the address stays short. Changing\n * any filter but the page sends the view back to its first page.\n */\nexport function applyFilters(\n search: string,\n prefix: string,\n patch: Record<string, string | number>,\n defaults: Record<string, string>,\n): string {\n const params = new URLSearchParams(search);\n const changesPage = 'page' in patch;\n\n for (const [name, value] of Object.entries(patch)) {\n const text = String(value);\n const unchanged = name === 'page' ? text === '1' : text === (defaults[name] ?? '');\n\n if (unchanged) {\n params.delete(key(prefix, name));\n } else {\n params.set(key(prefix, name), text);\n }\n }\n\n if (!changesPage) {\n params.delete(key(prefix, 'page'));\n }\n\n return params.toString();\n}\n"],"mappings":"AAMA,MAAM,MAAM,CAAC,QAAgB,SAAyB,GAAG,MAAM,IAAI,IAAI;AAGhE,SAAS,YACd,QACA,QACA,UACG;AACH,QAAM,SAAS,IAAI,gBAAgB,MAAM;AACzC,QAAM,MAA8B,CAAC;AAErC,aAAW,QAAQ,OAAO,KAAK,QAAQ,GAAG;AACxC,QAAI,IAAI,IAAI,OAAO,IAAI,IAAI,QAAQ,IAAI,CAAC,KAAK,SAAS,IAAI;AAAA,EAC5D;AAEA,SAAO;AACT;AAGO,SAAS,SAAS,QAAgB,QAAwB;AAC/D,QAAM,OAAO,OAAO,SAAS,IAAI,gBAAgB,MAAM,EAAE,IAAI,IAAI,QAAQ,MAAM,CAAC,KAAK,IAAI,EAAE;AAE3F,SAAO,OAAO,SAAS,IAAI,KAAK,OAAO,IAAI,OAAO;AACpD;AAMO,SAAS,aACd,QACA,QACA,OACA,UACQ;AACR,QAAM,SAAS,IAAI,gBAAgB,MAAM;AACzC,QAAM,cAAc,UAAU;AAE9B,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AACjD,UAAM,OAAO,OAAO,KAAK;AACzB,UAAM,YAAY,SAAS,SAAS,SAAS,MAAM,UAAU,SAAS,IAAI,KAAK;AAE/E,QAAI,WAAW;AACb,aAAO,OAAO,IAAI,QAAQ,IAAI,CAAC;AAAA,IACjC,OAAO;AACL,aAAO,IAAI,IAAI,QAAQ,IAAI,GAAG,IAAI;AAAA,IACpC;AAAA,EACF;AAEA,MAAI,CAAC,aAAa;AAChB,WAAO,OAAO,IAAI,QAAQ,MAAM,CAAC;AAAA,EACnC;AAEA,SAAO,OAAO,SAAS;AACzB;","names":[]}
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { useCallback, useMemo } from "react";
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
+ import { applyFilters, readFilters, readPage } from "./url-filters";
5
+ function useUrlFilters(prefix, defaults) {
6
+ const router = useRouter();
7
+ const pathname = usePathname();
8
+ const search = useSearchParams().toString();
9
+ const values = useMemo(() => readFilters(search, prefix, defaults), [search, prefix, defaults]);
10
+ const page = readPage(search, prefix);
11
+ const set = useCallback(
12
+ (patch) => {
13
+ const now = window.location.search.replace(/^\?/, "");
14
+ const next = applyFilters(now, prefix, patch, defaults);
15
+ router.replace(next ? `${pathname}?${next}` : pathname, { scroll: false });
16
+ },
17
+ [router, pathname, prefix, defaults]
18
+ );
19
+ return { values, page, set };
20
+ }
21
+ export {
22
+ useUrlFilters
23
+ };
24
+ //# sourceMappingURL=use-url-filters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/revenue/use-url-filters.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useMemo } from 'react';\nimport { usePathname, useRouter, useSearchParams } from 'next/navigation';\nimport { applyFilters, readFilters, readPage } from './url-filters';\n\n/**\n * A view's filters and page, kept in the page address. `set` changes some of them and sends the view back to its\n * first page; `set({ page })` only moves the page. The address is replaced, not added to, so Back leaves the view.\n */\nexport function useUrlFilters<T extends Record<string, string>>(prefix: string, defaults: T) {\n const router = useRouter();\n const pathname = usePathname();\n const search = useSearchParams().toString();\n const values = useMemo(() => readFilters(search, prefix, defaults), [search, prefix, defaults]);\n const page = readPage(search, prefix);\n\n const set = useCallback(\n (patch: Partial<Record<keyof T | 'page', string | number>>) => {\n // Read the address as it is now, so two changes in a row do not undo each other.\n const now = window.location.search.replace(/^\\?/, '');\n const next = applyFilters(now, prefix, patch as Record<string, string | number>, defaults);\n\n router.replace(next ? `${pathname}?${next}` : pathname, { scroll: false });\n },\n [router, pathname, prefix, defaults],\n );\n\n return { values, page, set };\n}\n"],"mappings":";AAEA,SAAS,aAAa,eAAe;AACrC,SAAS,aAAa,WAAW,uBAAuB;AACxD,SAAS,cAAc,aAAa,gBAAgB;AAM7C,SAAS,cAAgD,QAAgB,UAAa;AAC3F,QAAM,SAAS,UAAU;AACzB,QAAM,WAAW,YAAY;AAC7B,QAAM,SAAS,gBAAgB,EAAE,SAAS;AAC1C,QAAM,SAAS,QAAQ,MAAM,YAAY,QAAQ,QAAQ,QAAQ,GAAG,CAAC,QAAQ,QAAQ,QAAQ,CAAC;AAC9F,QAAM,OAAO,SAAS,QAAQ,MAAM;AAEpC,QAAM,MAAM;AAAA,IACV,CAAC,UAA8D;AAE7D,YAAM,MAAM,OAAO,SAAS,OAAO,QAAQ,OAAO,EAAE;AACpD,YAAM,OAAO,aAAa,KAAK,QAAQ,OAA0C,QAAQ;AAEzF,aAAO,QAAQ,OAAO,GAAG,QAAQ,IAAI,IAAI,KAAK,UAAU,EAAE,QAAQ,MAAM,CAAC;AAAA,IAC3E;AAAA,IACA,CAAC,QAAQ,UAAU,QAAQ,QAAQ;AAAA,EACrC;AAEA,SAAO,EAAE,QAAQ,MAAM,IAAI;AAC7B;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpotter/pages-admin",
3
- "version": "0.13.0-next.101",
3
+ "version": "0.13.0-next.102",
4
4
  "description": "Box console platform-admin routes: overview, revenue, accounts, tenants, admins, roles, audit.",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": false,
@@ -31,9 +31,9 @@
31
31
  "dependencies": {
32
32
  "@radix-ui/react-popover": "^1.1.23",
33
33
  "@tanstack/react-query": "^5.90.20",
34
- "@withpotter/lib": "^0.13.0-next.101",
35
- "@withpotter/platform-admin": "^0.13.0-next.101",
36
- "@withpotter/ui-kit": "^0.13.0-next.101",
34
+ "@withpotter/lib": "^0.13.0-next.102",
35
+ "@withpotter/platform-admin": "^0.13.0-next.102",
36
+ "@withpotter/ui-kit": "^0.13.0-next.102",
37
37
  "lucide-react": "^0.562.0",
38
38
  "motion": "^12.42.2"
39
39
  },