@withpotter/pages-admin 0.13.0-next.84 → 0.13.0-next.86

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 (51) hide show
  1. package/dist/admin/_money/finder-format.js +1 -0
  2. package/dist/admin/_money/finder-format.js.map +1 -1
  3. package/dist/admin/_money/format.js +1 -0
  4. package/dist/admin/_money/format.js.map +1 -1
  5. package/dist/admin/_overview/attention.module.css +0 -3
  6. package/dist/admin/_overview/overdue-outstanding.js +2 -1
  7. package/dist/admin/_overview/overdue-outstanding.js.map +1 -1
  8. package/dist/admin/_ui/list-card.module.css +2 -16
  9. package/dist/admin/_ui/money-view.js +1 -0
  10. package/dist/admin/_ui/money-view.js.map +1 -1
  11. package/dist/admin/_ui/page-header.js +31 -4
  12. package/dist/admin/_ui/page-header.js.map +1 -1
  13. package/dist/admin/_ui/page-header.module.css +45 -0
  14. package/dist/admin/_ui/panel.module.css +0 -10
  15. package/dist/admin/accounts/account-panel.js +43 -11
  16. package/dist/admin/accounts/account-panel.js.map +1 -1
  17. package/dist/admin/accounts/account-panel.module.css +59 -7
  18. package/dist/admin/accounts/account-row.js +2 -2
  19. package/dist/admin/accounts/account-row.js.map +1 -1
  20. package/dist/admin/accounts/account-sessions.js +28 -13
  21. package/dist/admin/accounts/account-sessions.js.map +1 -1
  22. package/dist/admin/accounts/account-sessions.module.css +4 -2
  23. package/dist/admin/accounts/account-stage.js +34 -0
  24. package/dist/admin/accounts/account-stage.js.map +1 -1
  25. package/dist/admin/accounts/account-vitals.js +15 -13
  26. package/dist/admin/accounts/account-vitals.js.map +1 -1
  27. package/dist/admin/audit/status-pill.js +6 -0
  28. package/dist/admin/audit/status-pill.js.map +1 -1
  29. package/dist/admin/audit/status-pill.module.css +4 -0
  30. package/dist/admin/commerce/commerce-overview.js +2 -2
  31. package/dist/admin/commerce/commerce-overview.js.map +1 -1
  32. package/dist/admin/commerce/merchant-meta.js +1 -0
  33. package/dist/admin/commerce/merchant-meta.js.map +1 -1
  34. package/dist/admin/commerce/merchant-view.js +2 -2
  35. package/dist/admin/commerce/merchant-view.js.map +1 -1
  36. package/dist/admin/feedback/prompt-operations.module.css +0 -2
  37. package/dist/admin/feedback/prompt-preview.module.css +0 -2
  38. package/dist/admin/revenue/revenue-kpis.js +1 -1
  39. package/dist/admin/revenue/revenue-kpis.js.map +1 -1
  40. package/dist/admin/revenue/revenue-workspace.js +1 -1
  41. package/dist/admin/revenue/revenue-workspace.js.map +1 -1
  42. package/dist/admin/support/queue-card.module.css +1 -4
  43. package/dist/admin/tenants/[id]/commerce/format.js +2 -1
  44. package/dist/admin/tenants/[id]/commerce/format.js.map +1 -1
  45. package/dist/admin/tenants/[id]/page.js +13 -6
  46. package/dist/admin/tenants/[id]/page.js.map +1 -1
  47. package/dist/admin/tenants/[id]/tenant-layout.module.css +1 -4
  48. package/dist/admin/tenants/[id]/tenant-overview.module.css +1 -1
  49. package/dist/admin/tenants/tenants-table.js +1 -1
  50. package/dist/admin/tenants/tenants-table.js.map +1 -1
  51. package/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
3
4
  import { Button, EmptyNote } from "@withpotter/ui-kit";
4
5
  import { AdmLoading } from "../_ui";
5
6
  import { useAccountSessions } from "../hooks";
@@ -17,11 +18,14 @@ const when = (iso) => iso ? new Date(iso).toLocaleString(void 0, {
17
18
  dateStyle: "medium",
18
19
  timeStyle: "short"
19
20
  }) : null;
21
+ const FIRST_PAGE = 5;
22
+ const NEXT_PAGE = 10;
20
23
  function deviceOf(s) {
21
24
  return s.name || s.kind || s.userAgent || "Unknown device";
22
25
  }
23
26
  function AccountSessions({ accountId }) {
24
27
  const { data, isLoading, isError, refetch } = useAccountSessions(accountId);
28
+ const [shown, setShown] = useState(FIRST_PAGE);
25
29
  if (isLoading) return /* @__PURE__ */ jsx(AdmLoading, { label: "Loading sessions\u2026", size: "sm" });
26
30
  if (isError) {
27
31
  return /* @__PURE__ */ jsxs(EmptyNote, { children: [
@@ -36,18 +40,29 @@ function AccountSessions({ accountId }) {
36
40
  const ordered = [...data].sort(
37
41
  (a, b) => b.startedAt.localeCompare(a.startedAt)
38
42
  );
39
- return /* @__PURE__ */ jsx("ul", { className: styles.list, children: ordered.map((s) => /* @__PURE__ */ jsxs("li", { className: styles.item, children: [
40
- /* @__PURE__ */ jsxs("span", { className: styles.top, children: [
41
- /* @__PURE__ */ jsx("span", { className: styles.device, title: s.userAgent ?? void 0, children: deviceOf(s) }),
42
- /* @__PURE__ */ jsx("span", { className: styles.how, "data-live": inUse(s), children: stateOf(s) })
43
- ] }),
44
- /* @__PURE__ */ jsx("span", { className: styles.meta, children: [s.ipAddress, s.location].filter(Boolean).join(" \xB7 ") || "No address recorded" }),
45
- /* @__PURE__ */ jsxs("span", { className: styles.meta, children: [
46
- "Started ",
47
- when(s.startedAt),
48
- s.endedAt ? ` \xB7 Ended ${when(s.endedAt)}` : ` \xB7 Last seen ${relativeTime(s.lastSeenAt)}`
49
- ] })
50
- ] }, s.id)) });
43
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
44
+ /* @__PURE__ */ jsx("ul", { className: styles.list, children: ordered.slice(0, shown).map((s) => /* @__PURE__ */ jsxs("li", { className: styles.item, children: [
45
+ /* @__PURE__ */ jsxs("span", { className: styles.top, children: [
46
+ /* @__PURE__ */ jsx("span", { className: styles.device, title: s.userAgent ?? void 0, children: deviceOf(s) }),
47
+ /* @__PURE__ */ jsx("span", { className: styles.how, "data-live": inUse(s), children: stateOf(s) })
48
+ ] }),
49
+ /* @__PURE__ */ jsx("span", { className: styles.meta, children: [s.ipAddress, s.location].filter(Boolean).join(" \xB7 ") || "No address recorded" }),
50
+ /* @__PURE__ */ jsxs("span", { className: styles.meta, children: [
51
+ "Started ",
52
+ when(s.startedAt),
53
+ s.endedAt ? ` \xB7 Ended ${when(s.endedAt)}` : ` \xB7 Last seen ${relativeTime(s.lastSeenAt)}`
54
+ ] })
55
+ ] }, s.id)) }),
56
+ ordered.length > shown && /* @__PURE__ */ jsx("div", { className: styles.more, children: /* @__PURE__ */ jsx(
57
+ Button,
58
+ {
59
+ variant: "secondary",
60
+ size: "sm",
61
+ onClick: () => setShown((n) => n + NEXT_PAGE),
62
+ children: "Show older"
63
+ }
64
+ ) })
65
+ ] });
51
66
  }
52
67
  export {
53
68
  AccountSessions
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/accounts/account-sessions.tsx"],"sourcesContent":["'use client';\n\nimport { Button, EmptyNote } from '@withpotter/ui-kit';\nimport type { AccountSession } from '@withpotter/lib/admin-api';\nimport { AdmLoading } from '../_ui';\nimport { useAccountSessions } from '../hooks';\nimport { relativeTime, seenWithin } from './relative-time';\nimport styles from './account-sessions.module.css';\n\nconst ENDED: Record<AccountSession['endedHow'], string> = {\n active: 'Signed in',\n revoked: 'Signed out',\n expired: 'Expired',\n};\n\n/** A device seen in the last few minutes is in use; a session not ended yet is only signed in. */\nconst IN_USE_SECONDS = 300;\nconst inUse = (s: AccountSession) =>\n s.endedHow === 'active' && seenWithin(s.lastSeenAt, IN_USE_SECONDS);\nconst stateOf = (s: AccountSession) =>\n inUse(s) ? 'Active now' : ENDED[s.endedHow];\n\nconst when = (iso: string | null) =>\n iso\n ? new Date(iso).toLocaleString(undefined, {\n dateStyle: 'medium',\n timeStyle: 'short',\n })\n : null;\n\n/** A short device name from the session, falling back to the raw agent. */\nfunction deviceOf(s: AccountSession): string {\n return s.name || s.kind || s.userAgent || 'Unknown device';\n}\n\n/** Where and when this person signed in, newest first. */\nexport function AccountSessions({ accountId }: { accountId: string }) {\n const { data, isLoading, isError, refetch } = useAccountSessions(accountId);\n\n if (isLoading) return <AdmLoading label=\"Loading sessions…\" size=\"sm\" />;\n\n if (isError) {\n return (\n <EmptyNote>\n We could not load their sessions just now.{' '}\n <Button variant=\"ghost\" size=\"sm\" onClick={() => void refetch()}>\n Try again\n </Button>\n </EmptyNote>\n );\n }\n\n if (!data?.length) {\n return <EmptyNote>No sign-ins recorded for this person yet.</EmptyNote>;\n }\n\n const ordered = [...data].sort((a: AccountSession, b: AccountSession) =>\n b.startedAt.localeCompare(a.startedAt),\n );\n\n return (\n <ul className={styles.list}>\n {ordered.map((s: AccountSession) => (\n <li key={s.id} className={styles.item}>\n <span className={styles.top}>\n <span className={styles.device} title={s.userAgent ?? undefined}>\n {deviceOf(s)}\n </span>\n <span className={styles.how} data-live={inUse(s)}>\n {stateOf(s)}\n </span>\n </span>\n <span className={styles.meta}>\n {[s.ipAddress, s.location].filter(Boolean).join(' · ') ||\n 'No address recorded'}\n </span>\n <span className={styles.meta}>\n Started {when(s.startedAt)}\n {s.endedAt\n ? ` · Ended ${when(s.endedAt)}`\n : ` · Last seen ${relativeTime(s.lastSeenAt)}`}\n </span>\n </li>\n ))}\n </ul>\n );\n}\n"],"mappings":";AAuCwB,cAIlB,YAJkB;AArCxB,SAAS,QAAQ,iBAAiB;AAElC,SAAS,kBAAkB;AAC3B,SAAS,0BAA0B;AACnC,SAAS,cAAc,kBAAkB;AACzC,OAAO,YAAY;AAEnB,MAAM,QAAoD;AAAA,EACxD,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AACX;AAGA,MAAM,iBAAiB;AACvB,MAAM,QAAQ,CAAC,MACb,EAAE,aAAa,YAAY,WAAW,EAAE,YAAY,cAAc;AACpE,MAAM,UAAU,CAAC,MACf,MAAM,CAAC,IAAI,eAAe,MAAM,EAAE,QAAQ;AAE5C,MAAM,OAAO,CAAC,QACZ,MACI,IAAI,KAAK,GAAG,EAAE,eAAe,QAAW;AAAA,EACtC,WAAW;AAAA,EACX,WAAW;AACb,CAAC,IACD;AAGN,SAAS,SAAS,GAA2B;AAC3C,SAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,aAAa;AAC5C;AAGO,SAAS,gBAAgB,EAAE,UAAU,GAA0B;AACpE,QAAM,EAAE,MAAM,WAAW,SAAS,QAAQ,IAAI,mBAAmB,SAAS;AAE1E,MAAI,UAAW,QAAO,oBAAC,cAAW,OAAM,0BAAoB,MAAK,MAAK;AAEtE,MAAI,SAAS;AACX,WACE,qBAAC,aAAU;AAAA;AAAA,MACkC;AAAA,MAC3C,oBAAC,UAAO,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,KAAK,QAAQ,GAAG,uBAEjE;AAAA,OACF;AAAA,EAEJ;AAEA,MAAI,CAAC,MAAM,QAAQ;AACjB,WAAO,oBAAC,aAAU,uDAAyC;AAAA,EAC7D;AAEA,QAAM,UAAU,CAAC,GAAG,IAAI,EAAE;AAAA,IAAK,CAAC,GAAmB,MACjD,EAAE,UAAU,cAAc,EAAE,SAAS;AAAA,EACvC;AAEA,SACE,oBAAC,QAAG,WAAW,OAAO,MACnB,kBAAQ,IAAI,CAAC,MACZ,qBAAC,QAAc,WAAW,OAAO,MAC/B;AAAA,yBAAC,UAAK,WAAW,OAAO,KACtB;AAAA,0BAAC,UAAK,WAAW,OAAO,QAAQ,OAAO,EAAE,aAAa,QACnD,mBAAS,CAAC,GACb;AAAA,MACA,oBAAC,UAAK,WAAW,OAAO,KAAK,aAAW,MAAM,CAAC,GAC5C,kBAAQ,CAAC,GACZ;AAAA,OACF;AAAA,IACA,oBAAC,UAAK,WAAW,OAAO,MACrB,WAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,OAAO,EAAE,KAAK,QAAK,KACnD,uBACJ;AAAA,IACA,qBAAC,UAAK,WAAW,OAAO,MAAM;AAAA;AAAA,MACnB,KAAK,EAAE,SAAS;AAAA,MACxB,EAAE,UACC,eAAY,KAAK,EAAE,OAAO,CAAC,KAC3B,mBAAgB,aAAa,EAAE,UAAU,CAAC;AAAA,OAChD;AAAA,OAlBO,EAAE,EAmBX,CACD,GACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/accounts/account-sessions.tsx"],"sourcesContent":["'use client';\n\nimport { useState } from 'react';\nimport { Button, EmptyNote } from '@withpotter/ui-kit';\nimport type { AccountSession } from '@withpotter/lib/admin-api';\nimport { AdmLoading } from '../_ui';\nimport { useAccountSessions } from '../hooks';\nimport { relativeTime, seenWithin } from './relative-time';\nimport styles from './account-sessions.module.css';\n\nconst ENDED: Record<AccountSession['endedHow'], string> = {\n active: 'Signed in',\n revoked: 'Signed out',\n expired: 'Expired',\n};\n\n/** A device seen in the last few minutes is in use; a session not ended yet is only signed in. */\nconst IN_USE_SECONDS = 300;\nconst inUse = (s: AccountSession) =>\n s.endedHow === 'active' && seenWithin(s.lastSeenAt, IN_USE_SECONDS);\nconst stateOf = (s: AccountSession) =>\n inUse(s) ? 'Active now' : ENDED[s.endedHow];\n\nconst when = (iso: string | null) =>\n iso\n ? new Date(iso).toLocaleString(undefined, {\n dateStyle: 'medium',\n timeStyle: 'short',\n })\n : null;\n\n/** Sessions shown first, and how many more each \"Show older\" adds. */\nconst FIRST_PAGE = 5;\nconst NEXT_PAGE = 10;\n\n/** A short device name from the session, falling back to the raw agent. */\nfunction deviceOf(s: AccountSession): string {\n return s.name || s.kind || s.userAgent || 'Unknown device';\n}\n\n/** Where and when this person signed in, newest first. */\nexport function AccountSessions({ accountId }: { accountId: string }) {\n const { data, isLoading, isError, refetch } = useAccountSessions(accountId);\n const [shown, setShown] = useState(FIRST_PAGE);\n\n if (isLoading) return <AdmLoading label=\"Loading sessions…\" size=\"sm\" />;\n\n if (isError) {\n return (\n <EmptyNote>\n We could not load their sessions just now.{' '}\n <Button variant=\"ghost\" size=\"sm\" onClick={() => void refetch()}>\n Try again\n </Button>\n </EmptyNote>\n );\n }\n\n if (!data?.length) {\n return <EmptyNote>No sign-ins recorded for this person yet.</EmptyNote>;\n }\n\n const ordered = [...data].sort((a: AccountSession, b: AccountSession) =>\n b.startedAt.localeCompare(a.startedAt),\n );\n\n return (\n <>\n <ul className={styles.list}>\n {ordered.slice(0, shown).map((s: AccountSession) => (\n <li key={s.id} className={styles.item}>\n <span className={styles.top}>\n <span className={styles.device} title={s.userAgent ?? undefined}>\n {deviceOf(s)}\n </span>\n <span className={styles.how} data-live={inUse(s)}>\n {stateOf(s)}\n </span>\n </span>\n <span className={styles.meta}>\n {[s.ipAddress, s.location].filter(Boolean).join(' · ') ||\n 'No address recorded'}\n </span>\n <span className={styles.meta}>\n Started {when(s.startedAt)}\n {s.endedAt\n ? ` · Ended ${when(s.endedAt)}`\n : ` · Last seen ${relativeTime(s.lastSeenAt)}`}\n </span>\n </li>\n ))}\n </ul>\n {ordered.length > shown && (\n <div className={styles.more}>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => setShown((n: number) => n + NEXT_PAGE)}\n >\n Show older\n </Button>\n </div>\n )}\n </>\n );\n}\n"],"mappings":";AA6CwB,SAsBpB,UAtBoB,KAIlB,YAJkB;AA3CxB,SAAS,gBAAgB;AACzB,SAAS,QAAQ,iBAAiB;AAElC,SAAS,kBAAkB;AAC3B,SAAS,0BAA0B;AACnC,SAAS,cAAc,kBAAkB;AACzC,OAAO,YAAY;AAEnB,MAAM,QAAoD;AAAA,EACxD,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AACX;AAGA,MAAM,iBAAiB;AACvB,MAAM,QAAQ,CAAC,MACb,EAAE,aAAa,YAAY,WAAW,EAAE,YAAY,cAAc;AACpE,MAAM,UAAU,CAAC,MACf,MAAM,CAAC,IAAI,eAAe,MAAM,EAAE,QAAQ;AAE5C,MAAM,OAAO,CAAC,QACZ,MACI,IAAI,KAAK,GAAG,EAAE,eAAe,QAAW;AAAA,EACtC,WAAW;AAAA,EACX,WAAW;AACb,CAAC,IACD;AAGN,MAAM,aAAa;AACnB,MAAM,YAAY;AAGlB,SAAS,SAAS,GAA2B;AAC3C,SAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,aAAa;AAC5C;AAGO,SAAS,gBAAgB,EAAE,UAAU,GAA0B;AACpE,QAAM,EAAE,MAAM,WAAW,SAAS,QAAQ,IAAI,mBAAmB,SAAS;AAC1E,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,UAAU;AAE7C,MAAI,UAAW,QAAO,oBAAC,cAAW,OAAM,0BAAoB,MAAK,MAAK;AAEtE,MAAI,SAAS;AACX,WACE,qBAAC,aAAU;AAAA;AAAA,MACkC;AAAA,MAC3C,oBAAC,UAAO,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,KAAK,QAAQ,GAAG,uBAEjE;AAAA,OACF;AAAA,EAEJ;AAEA,MAAI,CAAC,MAAM,QAAQ;AACjB,WAAO,oBAAC,aAAU,uDAAyC;AAAA,EAC7D;AAEA,QAAM,UAAU,CAAC,GAAG,IAAI,EAAE;AAAA,IAAK,CAAC,GAAmB,MACjD,EAAE,UAAU,cAAc,EAAE,SAAS;AAAA,EACvC;AAEA,SACE,iCACE;AAAA,wBAAC,QAAG,WAAW,OAAO,MACnB,kBAAQ,MAAM,GAAG,KAAK,EAAE,IAAI,CAAC,MAC5B,qBAAC,QAAc,WAAW,OAAO,MAC/B;AAAA,2BAAC,UAAK,WAAW,OAAO,KACtB;AAAA,4BAAC,UAAK,WAAW,OAAO,QAAQ,OAAO,EAAE,aAAa,QACnD,mBAAS,CAAC,GACb;AAAA,QACA,oBAAC,UAAK,WAAW,OAAO,KAAK,aAAW,MAAM,CAAC,GAC5C,kBAAQ,CAAC,GACZ;AAAA,SACF;AAAA,MACA,oBAAC,UAAK,WAAW,OAAO,MACrB,WAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,OAAO,EAAE,KAAK,QAAK,KACnD,uBACJ;AAAA,MACA,qBAAC,UAAK,WAAW,OAAO,MAAM;AAAA;AAAA,QACnB,KAAK,EAAE,SAAS;AAAA,QACxB,EAAE,UACC,eAAY,KAAK,EAAE,OAAO,CAAC,KAC3B,mBAAgB,aAAa,EAAE,UAAU,CAAC;AAAA,SAChD;AAAA,SAlBO,EAAE,EAmBX,CACD,GACH;AAAA,IACC,QAAQ,SAAS,SAChB,oBAAC,SAAI,WAAW,OAAO,MACrB;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM,SAAS,CAAC,MAAc,IAAI,SAAS;AAAA,QACrD;AAAA;AAAA,IAED,GACF;AAAA,KAEJ;AAEJ;","names":[]}
@@ -1,10 +1,8 @@
1
1
  .list {
2
2
  display: grid;
3
3
  gap: 8px;
4
- max-height: 320px;
5
4
  margin: 0;
6
5
  padding: 0;
7
- overflow-y: auto;
8
6
  list-style: none;
9
7
  }
10
8
 
@@ -51,3 +49,7 @@
51
49
  line-height: 16px;
52
50
  color: var(--text-tertiary);
53
51
  }
52
+
53
+ .more {
54
+ display: flex;
55
+ }
@@ -21,6 +21,17 @@ const KYC_LABELS = {
21
21
  DENIED: "Denied"
22
22
  };
23
23
  const ACTIVE_HINT = "Active means the email is verified, KYC or KYB is approved, and at least one sale was paid.";
24
+ const stageStatus = (stage) => `stage_${stage}`;
25
+ const PROVIDERS = {
26
+ local: "Email",
27
+ email: "Email",
28
+ google: "Google",
29
+ apple: "Apple"
30
+ };
31
+ function providerLabel(provider) {
32
+ const key = provider.toLowerCase();
33
+ return PROVIDERS[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
34
+ }
24
35
  function kycLabel(status) {
25
36
  if (!status) return null;
26
37
  return KYC_LABELS[status] ?? status;
@@ -36,12 +47,35 @@ function stoppedAt(a) {
36
47
  if (!a.firstSaleAt) return "Make a first sale";
37
48
  return null;
38
49
  }
50
+ function stepStates(a) {
51
+ const all = a.stage === "active";
52
+ const done = [
53
+ all || a.emailVerified === true,
54
+ all || (a.workspaceCount ?? 0) > 0,
55
+ all || a.kycStatus === "APPROVED",
56
+ all || Boolean(a.firstSaleAt)
57
+ ];
58
+ const labels = [
59
+ "Email verified",
60
+ "Business added",
61
+ "KYC or KYB approved",
62
+ "First sale"
63
+ ];
64
+ const current = done.indexOf(false);
65
+ return labels.map((label, i) => ({
66
+ label,
67
+ state: done[i] ? "done" : i === current ? "current" : "todo"
68
+ }));
69
+ }
39
70
  export {
40
71
  ACTIVE_HINT,
41
72
  KYC_LABELS,
42
73
  STAGE_LABELS,
43
74
  STAGE_ORDER,
44
75
  kycLabel,
76
+ providerLabel,
77
+ stageStatus,
78
+ stepStates,
45
79
  stoppedAt
46
80
  };
47
81
  //# sourceMappingURL=account-stage.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/accounts/account-stage.ts"],"sourcesContent":["import type {\n AccountKycStatus,\n AccountRow,\n AccountStage,\n} from '@withpotter/lib/admin-api';\n\nexport const STAGE_LABELS: Record<AccountStage, string> = {\n signed_up: 'Signed up',\n verified: 'Email verified',\n onboarding: 'Onboarding',\n kyc_approved: 'KYC approved',\n active: 'Active',\n};\n\nexport const STAGE_ORDER: AccountStage[] = [\n 'signed_up',\n 'verified',\n 'onboarding',\n 'kyc_approved',\n 'active',\n];\n\nexport const KYC_LABELS: Record<AccountKycStatus, string> = {\n NOT_STARTED: 'Not started',\n CHECKING: 'Checking',\n WAITING_REVIEW: 'In review',\n NEEDS_MORE: 'Needs more',\n APPROVED: 'Approved',\n DENIED: 'Denied',\n};\n\n/** What \"active\" means, said once for every place that needs it. */\nexport const ACTIVE_HINT =\n 'Active means the email is verified, KYC or KYB is approved, and at least one sale was paid.';\n\n/** Plain words for a KYC or KYB status; the raw value for unknown ones. */\nexport function kycLabel(status: string | null | undefined): string | null {\n if (!status) return null;\n\n return KYC_LABELS[status as AccountKycStatus] ?? status;\n}\n\ntype Progress = Pick<\n AccountRow,\n 'emailVerified' | 'workspaceCount' | 'kycStatus' | 'firstSaleAt' | 'stage'\n>;\n\n/**\n * The first step this account has not done, from the fields the list sends.\n * Returns null when every step is done (or when nothing is known yet).\n */\nexport function stoppedAt(a: Partial<Progress>): string | null {\n if (a.stage === undefined && a.emailVerified === undefined) return null;\n if (a.stage === 'active') return null;\n if (a.emailVerified === false) return 'Verify their email';\n if (a.workspaceCount === 0) return 'Add a business';\n if (a.kycStatus !== 'APPROVED') {\n return a.kycStatus === 'DENIED'\n ? 'Finish KYC or KYB (denied)'\n : 'Finish KYC or KYB';\n }\n if (!a.firstSaleAt) return 'Make a first sale';\n\n return null;\n}\n"],"mappings":"AAMO,MAAM,eAA6C;AAAA,EACxD,WAAW;AAAA,EACX,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,QAAQ;AACV;AAEO,MAAM,cAA8B;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEO,MAAM,aAA+C;AAAA,EAC1D,aAAa;AAAA,EACb,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,QAAQ;AACV;AAGO,MAAM,cACX;AAGK,SAAS,SAAS,QAAkD;AACzE,MAAI,CAAC,OAAQ,QAAO;AAEpB,SAAO,WAAW,MAA0B,KAAK;AACnD;AAWO,SAAS,UAAU,GAAqC;AAC7D,MAAI,EAAE,UAAU,UAAa,EAAE,kBAAkB,OAAW,QAAO;AACnE,MAAI,EAAE,UAAU,SAAU,QAAO;AACjC,MAAI,EAAE,kBAAkB,MAAO,QAAO;AACtC,MAAI,EAAE,mBAAmB,EAAG,QAAO;AACnC,MAAI,EAAE,cAAc,YAAY;AAC9B,WAAO,EAAE,cAAc,WACnB,+BACA;AAAA,EACN;AACA,MAAI,CAAC,EAAE,YAAa,QAAO;AAE3B,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/accounts/account-stage.ts"],"sourcesContent":["import type {\n AccountKycStatus,\n AccountRow,\n AccountStage,\n} from '@withpotter/lib/admin-api';\n\nexport const STAGE_LABELS: Record<AccountStage, string> = {\n signed_up: 'Signed up',\n verified: 'Email verified',\n onboarding: 'Onboarding',\n kyc_approved: 'KYC approved',\n active: 'Active',\n};\n\nexport const STAGE_ORDER: AccountStage[] = [\n 'signed_up',\n 'verified',\n 'onboarding',\n 'kyc_approved',\n 'active',\n];\n\nexport const KYC_LABELS: Record<AccountKycStatus, string> = {\n NOT_STARTED: 'Not started',\n CHECKING: 'Checking',\n WAITING_REVIEW: 'In review',\n NEEDS_MORE: 'Needs more',\n APPROVED: 'Approved',\n DENIED: 'Denied',\n};\n\n/** What \"active\" means, said once for every place that needs it. */\nexport const ACTIVE_HINT =\n 'Active means the email is verified, KYC or KYB is approved, and at least one sale was paid.';\n\n/** The StatusPill key for a stage, so each stage gets its own tone. */\nexport const stageStatus = (stage: AccountStage): string => `stage_${stage}`;\n\nconst PROVIDERS: Record<string, string> = {\n local: 'Email',\n email: 'Email',\n google: 'Google',\n apple: 'Apple',\n};\n\n/** Plain words for a sign-in provider; the capitalised value for unknown ones. */\nexport function providerLabel(provider: string): string {\n const key = provider.toLowerCase();\n\n return PROVIDERS[key] ?? key.charAt(0).toUpperCase() + key.slice(1);\n}\n\n/** Plain words for a KYC or KYB status; the raw value for unknown ones. */\nexport function kycLabel(status: string | null | undefined): string | null {\n if (!status) return null;\n\n return KYC_LABELS[status as AccountKycStatus] ?? status;\n}\n\ntype Progress = Pick<\n AccountRow,\n 'emailVerified' | 'workspaceCount' | 'kycStatus' | 'firstSaleAt' | 'stage'\n>;\n\n/**\n * The first step this account has not done, from the fields the list sends.\n * Returns null when every step is done (or when nothing is known yet).\n */\nexport function stoppedAt(a: Partial<Progress>): string | null {\n if (a.stage === undefined && a.emailVerified === undefined) return null;\n if (a.stage === 'active') return null;\n if (a.emailVerified === false) return 'Verify their email';\n if (a.workspaceCount === 0) return 'Add a business';\n if (a.kycStatus !== 'APPROVED') {\n return a.kycStatus === 'DENIED'\n ? 'Finish KYC or KYB (denied)'\n : 'Finish KYC or KYB';\n }\n if (!a.firstSaleAt) return 'Make a first sale';\n\n return null;\n}\n\nexport type StepState = 'done' | 'current' | 'todo';\n\n/**\n * The four steps to an active account, each done, current (the first not\n * done) or todo, from the same fields as stoppedAt.\n */\nexport function stepStates(\n a: Partial<Progress>,\n): { label: string; state: StepState }[] {\n const all = a.stage === 'active';\n const done = [\n all || a.emailVerified === true,\n all || (a.workspaceCount ?? 0) > 0,\n all || a.kycStatus === 'APPROVED',\n all || Boolean(a.firstSaleAt),\n ];\n const labels = [\n 'Email verified',\n 'Business added',\n 'KYC or KYB approved',\n 'First sale',\n ];\n const current = done.indexOf(false);\n\n return labels.map((label: string, i: number) => ({\n label,\n state: done[i] ? 'done' : i === current ? 'current' : 'todo',\n }));\n}\n"],"mappings":"AAMO,MAAM,eAA6C;AAAA,EACxD,WAAW;AAAA,EACX,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,QAAQ;AACV;AAEO,MAAM,cAA8B;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEO,MAAM,aAA+C;AAAA,EAC1D,aAAa;AAAA,EACb,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,QAAQ;AACV;AAGO,MAAM,cACX;AAGK,MAAM,cAAc,CAAC,UAAgC,SAAS,KAAK;AAE1E,MAAM,YAAoC;AAAA,EACxC,OAAO;AAAA,EACP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AACT;AAGO,SAAS,cAAc,UAA0B;AACtD,QAAM,MAAM,SAAS,YAAY;AAEjC,SAAO,UAAU,GAAG,KAAK,IAAI,OAAO,CAAC,EAAE,YAAY,IAAI,IAAI,MAAM,CAAC;AACpE;AAGO,SAAS,SAAS,QAAkD;AACzE,MAAI,CAAC,OAAQ,QAAO;AAEpB,SAAO,WAAW,MAA0B,KAAK;AACnD;AAWO,SAAS,UAAU,GAAqC;AAC7D,MAAI,EAAE,UAAU,UAAa,EAAE,kBAAkB,OAAW,QAAO;AACnE,MAAI,EAAE,UAAU,SAAU,QAAO;AACjC,MAAI,EAAE,kBAAkB,MAAO,QAAO;AACtC,MAAI,EAAE,mBAAmB,EAAG,QAAO;AACnC,MAAI,EAAE,cAAc,YAAY;AAC9B,WAAO,EAAE,cAAc,WACnB,+BACA;AAAA,EACN;AACA,MAAI,CAAC,EAAE,YAAa,QAAO;AAE3B,SAAO;AACT;AAQO,SAAS,WACd,GACuC;AACvC,QAAM,MAAM,EAAE,UAAU;AACxB,QAAM,OAAO;AAAA,IACX,OAAO,EAAE,kBAAkB;AAAA,IAC3B,QAAQ,EAAE,kBAAkB,KAAK;AAAA,IACjC,OAAO,EAAE,cAAc;AAAA,IACvB,OAAO,QAAQ,EAAE,WAAW;AAAA,EAC9B;AACA,QAAM,SAAS;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,QAAM,UAAU,KAAK,QAAQ,KAAK;AAElC,SAAO,OAAO,IAAI,CAAC,OAAe,OAAe;AAAA,IAC/C;AAAA,IACA,OAAO,KAAK,CAAC,IAAI,SAAS,MAAM,UAAU,YAAY;AAAA,EACxD,EAAE;AACJ;","names":[]}
@@ -1,35 +1,37 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { relativeTime, seenWithin } from "./relative-time";
3
3
  import styles from "./account-panel.module.css";
4
+ const SEEN_GAP_MS = 5 * 60 * 1e3;
4
5
  function AccountVitals({
5
6
  account,
6
7
  workspaces
7
8
  }) {
8
- const online = seenWithin(account.lastSeenAt, 15 * 60);
9
+ const online = Boolean(seenWithin(account.lastSeenAt, 15 * 60));
10
+ const laterSeen = account.lastSeenAt && (!account.lastLoginAt || new Date(account.lastSeenAt).getTime() - new Date(account.lastLoginAt).getTime() > SEEN_GAP_MS);
9
11
  const tiles = [
10
- {
11
- label: "Last seen",
12
- value: relativeTime(account.lastSeenAt ?? account.lastLoginAt),
13
- hint: account.activeSessions !== void 0 ? `${account.activeSessions} signed-in device${account.activeSessions === 1 ? "" : "s"}` : void 0,
14
- live: Boolean(online)
15
- },
16
12
  {
17
13
  label: "Last login",
18
14
  value: relativeTime(account.lastLoginAt),
19
- hint: account.lastLoginAt ? new Date(account.lastLoginAt).toLocaleDateString() : void 0
15
+ hint: account.lastLoginAt ? new Date(account.lastLoginAt).toLocaleDateString() : void 0,
16
+ live: online && !laterSeen
20
17
  },
18
+ ...laterSeen ? [
19
+ {
20
+ label: "Last seen",
21
+ value: relativeTime(account.lastSeenAt),
22
+ live: online
23
+ }
24
+ ] : [],
21
25
  {
22
26
  label: "Workspaces",
23
- value: workspaces === void 0 ? "\u2026" : String(workspaces),
24
- hint: "Active memberships"
27
+ value: workspaces === void 0 ? "\u2026" : String(workspaces)
25
28
  },
26
29
  {
27
30
  label: "Joined",
28
- value: account.createdAt ? relativeTime(account.createdAt) : "\u2014",
29
- hint: account.createdAt ? new Date(account.createdAt).toLocaleDateString() : void 0
31
+ value: account.createdAt ? new Date(account.createdAt).toLocaleDateString() : "\u2014"
30
32
  }
31
33
  ];
32
- return /* @__PURE__ */ jsx("div", { className: styles.vitals, children: tiles.map((tile) => /* @__PURE__ */ jsxs("div", { className: styles.vital, children: [
34
+ return /* @__PURE__ */ jsx("div", { className: styles.vitals, "data-count": tiles.length, children: tiles.map((tile) => /* @__PURE__ */ jsxs("div", { className: styles.vital, children: [
33
35
  /* @__PURE__ */ jsxs("span", { className: styles.vitalLabel, children: [
34
36
  tile.live && /* @__PURE__ */ jsx("span", { className: styles.live, "aria-label": "Online" }),
35
37
  tile.label
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/accounts/account-vitals.tsx"],"sourcesContent":["import type { AccountRow } from '@withpotter/lib/admin-api';\nimport { relativeTime, seenWithin } from './relative-time';\nimport styles from './account-panel.module.css';\n\n/** Four numbers that say whether this person is here and how deeply. */\nexport function AccountVitals({\n account,\n workspaces,\n}: {\n account: Pick<AccountRow, 'id'> & Partial<AccountRow>;\n workspaces: number | undefined;\n}) {\n const online = seenWithin(account.lastSeenAt, 15 * 60);\n const tiles = [\n {\n label: 'Last seen',\n value: relativeTime(account.lastSeenAt ?? account.lastLoginAt),\n hint:\n account.activeSessions !== undefined\n ? `${account.activeSessions} signed-in device${account.activeSessions === 1 ? '' : 's'}`\n : undefined,\n live: Boolean(online),\n },\n {\n label: 'Last login',\n value: relativeTime(account.lastLoginAt),\n hint: account.lastLoginAt\n ? new Date(account.lastLoginAt).toLocaleDateString()\n : undefined,\n },\n {\n label: 'Workspaces',\n value: workspaces === undefined ? '…' : String(workspaces),\n hint: 'Active memberships',\n },\n {\n label: 'Joined',\n value: account.createdAt ? relativeTime(account.createdAt) : '—',\n hint: account.createdAt\n ? new Date(account.createdAt).toLocaleDateString()\n : undefined,\n },\n ];\n\n return (\n <div className={styles.vitals}>\n {tiles.map((tile) => (\n <div key={tile.label} className={styles.vital}>\n <span className={styles.vitalLabel}>\n {tile.live && <span className={styles.live} aria-label=\"Online\" />}\n {tile.label}\n </span>\n <span className={styles.vitalValue}>{tile.value}</span>\n {tile.hint && <span className={styles.vitalHint}>{tile.hint}</span>}\n </div>\n ))}\n </div>\n );\n}\n"],"mappings":"AAgDU,SACgB,KADhB;AA/CV,SAAS,cAAc,kBAAkB;AACzC,OAAO,YAAY;AAGZ,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AACF,GAGG;AACD,QAAM,SAAS,WAAW,QAAQ,YAAY,KAAK,EAAE;AACrD,QAAM,QAAQ;AAAA,IACZ;AAAA,MACE,OAAO;AAAA,MACP,OAAO,aAAa,QAAQ,cAAc,QAAQ,WAAW;AAAA,MAC7D,MACE,QAAQ,mBAAmB,SACvB,GAAG,QAAQ,cAAc,oBAAoB,QAAQ,mBAAmB,IAAI,KAAK,GAAG,KACpF;AAAA,MACN,MAAM,QAAQ,MAAM;AAAA,IACtB;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,OAAO,aAAa,QAAQ,WAAW;AAAA,MACvC,MAAM,QAAQ,cACV,IAAI,KAAK,QAAQ,WAAW,EAAE,mBAAmB,IACjD;AAAA,IACN;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,OAAO,eAAe,SAAY,WAAM,OAAO,UAAU;AAAA,MACzD,MAAM;AAAA,IACR;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,OAAO,QAAQ,YAAY,aAAa,QAAQ,SAAS,IAAI;AAAA,MAC7D,MAAM,QAAQ,YACV,IAAI,KAAK,QAAQ,SAAS,EAAE,mBAAmB,IAC/C;AAAA,IACN;AAAA,EACF;AAEA,SACE,oBAAC,SAAI,WAAW,OAAO,QACpB,gBAAM,IAAI,CAAC,SACV,qBAAC,SAAqB,WAAW,OAAO,OACtC;AAAA,yBAAC,UAAK,WAAW,OAAO,YACrB;AAAA,WAAK,QAAQ,oBAAC,UAAK,WAAW,OAAO,MAAM,cAAW,UAAS;AAAA,MAC/D,KAAK;AAAA,OACR;AAAA,IACA,oBAAC,UAAK,WAAW,OAAO,YAAa,eAAK,OAAM;AAAA,IAC/C,KAAK,QAAQ,oBAAC,UAAK,WAAW,OAAO,WAAY,eAAK,MAAK;AAAA,OANpD,KAAK,KAOf,CACD,GACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/accounts/account-vitals.tsx"],"sourcesContent":["import type { AccountRow } from '@withpotter/lib/admin-api';\nimport { relativeTime, seenWithin } from './relative-time';\nimport styles from './account-panel.module.css';\n\n/** Last seen only earns a tile when it is meaningfully after the last login. */\nconst SEEN_GAP_MS = 5 * 60 * 1000;\n\ntype Tile = { label: string; value: string; hint?: string; live?: boolean };\n\n/** Three numbers (a fourth when last seen differs) that say how deeply this person is here. */\nexport function AccountVitals({\n account,\n workspaces,\n}: {\n account: Pick<AccountRow, 'id'> & Partial<AccountRow>;\n workspaces: number | undefined;\n}) {\n const online = Boolean(seenWithin(account.lastSeenAt, 15 * 60));\n const laterSeen =\n account.lastSeenAt &&\n (!account.lastLoginAt ||\n new Date(account.lastSeenAt).getTime() -\n new Date(account.lastLoginAt).getTime() >\n SEEN_GAP_MS);\n const tiles: Tile[] = [\n {\n label: 'Last login',\n value: relativeTime(account.lastLoginAt),\n hint: account.lastLoginAt\n ? new Date(account.lastLoginAt).toLocaleDateString()\n : undefined,\n live: online && !laterSeen,\n },\n ...(laterSeen\n ? [\n {\n label: 'Last seen',\n value: relativeTime(account.lastSeenAt),\n live: online,\n },\n ]\n : []),\n {\n label: 'Workspaces',\n value: workspaces === undefined ? '…' : String(workspaces),\n },\n {\n label: 'Joined',\n value: account.createdAt\n ? new Date(account.createdAt).toLocaleDateString()\n : '—',\n },\n ];\n\n return (\n <div className={styles.vitals} data-count={tiles.length}>\n {tiles.map((tile: Tile) => (\n <div key={tile.label} className={styles.vital}>\n <span className={styles.vitalLabel}>\n {tile.live && <span className={styles.live} aria-label=\"Online\" />}\n {tile.label}\n </span>\n <span className={styles.vitalValue}>{tile.value}</span>\n {tile.hint && <span className={styles.vitalHint}>{tile.hint}</span>}\n </div>\n ))}\n </div>\n );\n}\n"],"mappings":"AA0DU,SACgB,KADhB;AAzDV,SAAS,cAAc,kBAAkB;AACzC,OAAO,YAAY;AAGnB,MAAM,cAAc,IAAI,KAAK;AAKtB,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AACF,GAGG;AACD,QAAM,SAAS,QAAQ,WAAW,QAAQ,YAAY,KAAK,EAAE,CAAC;AAC9D,QAAM,YACJ,QAAQ,eACP,CAAC,QAAQ,eACR,IAAI,KAAK,QAAQ,UAAU,EAAE,QAAQ,IACnC,IAAI,KAAK,QAAQ,WAAW,EAAE,QAAQ,IACtC;AACN,QAAM,QAAgB;AAAA,IACpB;AAAA,MACE,OAAO;AAAA,MACP,OAAO,aAAa,QAAQ,WAAW;AAAA,MACvC,MAAM,QAAQ,cACV,IAAI,KAAK,QAAQ,WAAW,EAAE,mBAAmB,IACjD;AAAA,MACJ,MAAM,UAAU,CAAC;AAAA,IACnB;AAAA,IACA,GAAI,YACA;AAAA,MACE;AAAA,QACE,OAAO;AAAA,QACP,OAAO,aAAa,QAAQ,UAAU;AAAA,QACtC,MAAM;AAAA,MACR;AAAA,IACF,IACA,CAAC;AAAA,IACL;AAAA,MACE,OAAO;AAAA,MACP,OAAO,eAAe,SAAY,WAAM,OAAO,UAAU;AAAA,IAC3D;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,OAAO,QAAQ,YACX,IAAI,KAAK,QAAQ,SAAS,EAAE,mBAAmB,IAC/C;AAAA,IACN;AAAA,EACF;AAEA,SACE,oBAAC,SAAI,WAAW,OAAO,QAAQ,cAAY,MAAM,QAC9C,gBAAM,IAAI,CAAC,SACV,qBAAC,SAAqB,WAAW,OAAO,OACtC;AAAA,yBAAC,UAAK,WAAW,OAAO,YACrB;AAAA,WAAK,QAAQ,oBAAC,UAAK,WAAW,OAAO,MAAM,cAAW,UAAS;AAAA,MAC/D,KAAK;AAAA,OACR;AAAA,IACA,oBAAC,UAAK,WAAW,OAAO,YAAa,eAAK,OAAM;AAAA,IAC/C,KAAK,QAAQ,oBAAC,UAAK,WAAW,OAAO,WAAY,eAAK,MAAK;AAAA,OANpD,KAAK,KAOf,CACD,GACH;AAEJ;","names":[]}
@@ -23,6 +23,12 @@ const TONE_OF = {
23
23
  completed: "complete",
24
24
  closed: "complete",
25
25
  draft: "draft",
26
+ // Account stages read as progress: grey, blue, amber, teal, green.
27
+ stage_signed_up: "neutral",
28
+ stage_verified: "processing",
29
+ stage_onboarding: "pending",
30
+ stage_kyc_approved: "ahead",
31
+ stage_active: "success",
26
32
  deleted: "neutral",
27
33
  rejected: "neutral"
28
34
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/audit/status-pill.tsx"],"sourcesContent":["import styles from './status-pill.module.css';\n\ntype StatusTone =\n | 'success'\n | 'pending'\n | 'failed'\n | 'processing'\n | 'complete'\n | 'draft'\n | 'neutral';\n\nconst TONE_OF: Record<string, StatusTone> = {\n active: 'success',\n verified: 'success',\n approved: 'success',\n resolved: 'success',\n pending: 'pending',\n pending_verification: 'pending',\n unverified: 'pending',\n invited: 'pending',\n paused: 'pending',\n open: 'pending',\n non_renewing: 'pending',\n trialing: 'processing',\n processing: 'processing',\n scheduled: 'processing',\n acknowledged: 'processing',\n suspended: 'failed',\n failed: 'failed',\n flagged: 'failed',\n past_due: 'failed',\n completed: 'complete',\n closed: 'complete',\n draft: 'draft',\n deleted: 'neutral',\n rejected: 'neutral',\n};\n\n/** Words for a status key: `pending_verification` → \"Pending verification\". */\nconst words = (s: string) => {\n const w = s.replace(/[_-]+/g, ' ');\n return w.charAt(0).toUpperCase() + w.slice(1);\n};\n\n/**\n * A status as a 6px dot plus text. Each state reads as itself: live ok,\n * waiting amber, in flight brand blue, stopped red, finished solid grey,\n * draft hollow. Static: nothing pulses.\n */\nexport function StatusPill({\n status,\n label,\n}: {\n status: string;\n label?: string;\n}) {\n const tone = TONE_OF[status.toLowerCase()] ?? 'neutral';\n return (\n <span className={styles.pill} data-tone={tone}>\n <span className={`${styles.dot} adm-status-dot`} aria-hidden />\n {label ?? words(status)}\n </span>\n );\n}\n"],"mappings":"AA0DI,SACE,KADF;AA1DJ,OAAO,YAAY;AAWnB,MAAM,UAAsC;AAAA,EAC1C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,SAAS;AAAA,EACT,sBAAsB;AAAA,EACtB,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,cAAc;AAAA,EACd,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,cAAc;AAAA,EACd,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,SAAS;AAAA,EACT,UAAU;AACZ;AAGA,MAAM,QAAQ,CAAC,MAAc;AAC3B,QAAM,IAAI,EAAE,QAAQ,UAAU,GAAG;AACjC,SAAO,EAAE,OAAO,CAAC,EAAE,YAAY,IAAI,EAAE,MAAM,CAAC;AAC9C;AAOO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AACF,GAGG;AACD,QAAM,OAAO,QAAQ,OAAO,YAAY,CAAC,KAAK;AAC9C,SACE,qBAAC,UAAK,WAAW,OAAO,MAAM,aAAW,MACvC;AAAA,wBAAC,UAAK,WAAW,GAAG,OAAO,GAAG,mBAAmB,eAAW,MAAC;AAAA,IAC5D,SAAS,MAAM,MAAM;AAAA,KACxB;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/audit/status-pill.tsx"],"sourcesContent":["import styles from './status-pill.module.css';\n\ntype StatusTone =\n | 'success'\n | 'pending'\n | 'failed'\n | 'processing'\n | 'complete'\n | 'draft'\n | 'ahead'\n | 'neutral';\n\nconst TONE_OF: Record<string, StatusTone> = {\n active: 'success',\n verified: 'success',\n approved: 'success',\n resolved: 'success',\n pending: 'pending',\n pending_verification: 'pending',\n unverified: 'pending',\n invited: 'pending',\n paused: 'pending',\n open: 'pending',\n non_renewing: 'pending',\n trialing: 'processing',\n processing: 'processing',\n scheduled: 'processing',\n acknowledged: 'processing',\n suspended: 'failed',\n failed: 'failed',\n flagged: 'failed',\n past_due: 'failed',\n completed: 'complete',\n closed: 'complete',\n draft: 'draft',\n // Account stages read as progress: grey, blue, amber, teal, green.\n stage_signed_up: 'neutral',\n stage_verified: 'processing',\n stage_onboarding: 'pending',\n stage_kyc_approved: 'ahead',\n stage_active: 'success',\n deleted: 'neutral',\n rejected: 'neutral',\n};\n\n/** Words for a status key: `pending_verification` → \"Pending verification\". */\nconst words = (s: string) => {\n const w = s.replace(/[_-]+/g, ' ');\n return w.charAt(0).toUpperCase() + w.slice(1);\n};\n\n/**\n * A status as a 6px dot plus text. Each state reads as itself: live ok,\n * waiting amber, in flight brand blue, stopped red, finished solid grey,\n * draft hollow. Static: nothing pulses.\n */\nexport function StatusPill({\n status,\n label,\n}: {\n status: string;\n label?: string;\n}) {\n const tone = TONE_OF[status.toLowerCase()] ?? 'neutral';\n return (\n <span className={styles.pill} data-tone={tone}>\n <span className={`${styles.dot} adm-status-dot`} aria-hidden />\n {label ?? words(status)}\n </span>\n );\n}\n"],"mappings":"AAiEI,SACE,KADF;AAjEJ,OAAO,YAAY;AAYnB,MAAM,UAAsC;AAAA,EAC1C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,SAAS;AAAA,EACT,sBAAsB;AAAA,EACtB,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,cAAc;AAAA,EACd,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,cAAc;AAAA,EACd,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,OAAO;AAAA;AAAA,EAEP,iBAAiB;AAAA,EACjB,gBAAgB;AAAA,EAChB,kBAAkB;AAAA,EAClB,oBAAoB;AAAA,EACpB,cAAc;AAAA,EACd,SAAS;AAAA,EACT,UAAU;AACZ;AAGA,MAAM,QAAQ,CAAC,MAAc;AAC3B,QAAM,IAAI,EAAE,QAAQ,UAAU,GAAG;AACjC,SAAO,EAAE,OAAO,CAAC,EAAE,YAAY,IAAI,EAAE,MAAM,CAAC;AAC9C;AAOO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AACF,GAGG;AACD,QAAM,OAAO,QAAQ,OAAO,YAAY,CAAC,KAAK;AAC9C,SACE,qBAAC,UAAK,WAAW,OAAO,MAAM,aAAW,MACvC;AAAA,wBAAC,UAAK,WAAW,GAAG,OAAO,GAAG,mBAAmB,eAAW,MAAC;AAAA,IAC5D,SAAS,MAAM,MAAM;AAAA,KACxB;AAEJ;","names":[]}
@@ -27,6 +27,10 @@
27
27
  --tone: var(--brand-500);
28
28
  }
29
29
 
30
+ .pill[data-tone='ahead'] {
31
+ --tone: color-mix(in srgb, var(--adm-ok) 55%, var(--brand-500));
32
+ }
33
+
30
34
  .pill[data-tone='failed'] {
31
35
  --tone: var(--error);
32
36
  }
@@ -70,7 +70,7 @@ function CommerceOverview({
70
70
  previousOrdersTrend: [],
71
71
  compare: false,
72
72
  span,
73
- money: moneyIn(focus ?? "USD")
73
+ money: moneyIn(focus ?? "NGN")
74
74
  }
75
75
  ) : pick }) }) }),
76
76
  /* @__PURE__ */ jsxs("div", { className: "adm-grid", children: [
@@ -79,7 +79,7 @@ function CommerceOverview({
79
79
  {
80
80
  rows: rowFor(data?.topTenantsByCurrency, focus)?.tenants ?? [],
81
81
  loading,
82
- money: moneyIn(focus ?? "USD"),
82
+ money: moneyIn(focus ?? "NGN"),
83
83
  caption,
84
84
  onOpen: onOpenMerchant
85
85
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/commerce/commerce-overview.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { Button, ErrorBanner } from '@withpotter/ui-kit';\nimport { usePlatformCockpit } from '../hooks';\nimport {\n GmvOrdersChart,\n PICK_CURRENCY,\n Panel,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rowFor,\n toTrend,\n type DateRange,\n} from '../_ui';\nimport { OverviewKpis } from './overview-kpis';\nimport { SalesByService } from './overview-services';\nimport { TopMerchants } from './overview-top-merchants';\nimport styles from './overview.module.css';\n\n/**\n * Commerce across every merchant, from the one cockpit call: the headline\n * figures, who sells most, where the base sits and who needs a look.\n * Nothing here is fetched per merchant. Money comes back once per currency\n * and is never added across currencies or converted.\n */\nexport function CommerceOverview({\n range,\n currency,\n compare,\n onOpenMerchant,\n}: {\n range: DateRange;\n /** The currency filter's choice; '' shows every currency. */\n currency: string;\n /** False for \"all time\": there is no earlier period to compare with. */\n compare: boolean;\n onOpenMerchant: (id: string) => void;\n}) {\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n const data = cockpit.data;\n const currencies = data?.currencies ?? [];\n // A chart or a ranking draws one currency: the filter's, or the only one.\n const focus = focusCurrency(currencies, currency);\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n const caption = focus ? `${period} · ${focus}` : period;\n\n const series = useMemo(\n () => rowFor(data?.trend.performanceSeriesByCurrency, focus)?.series,\n [data, focus],\n );\n const gmvTrend = useMemo(() => toTrend(series, 'gmv'), [series]);\n const ordersTrend = useMemo(() => toTrend(series, 'orders'), [series]);\n // Sales by service is one mixed figure: only honest with one currency.\n const services =\n data?.salesByService?.length && currencies.length === 1\n ? data.salesByService\n : null;\n\n if (cockpit.isError) {\n return (\n <ErrorBanner>\n The commerce overview could not be loaded.{' '}\n <Button size=\"sm\" variant=\"secondary\" onClick={() => cockpit.refetch()}>\n Try again\n </Button>\n </ErrorBanner>\n );\n }\n\n const loading = cockpit.isLoading || !data;\n const pick = (\n <p className=\"text-sm text-[var(--text-secondary)]\">{PICK_CURRENCY}</p>\n );\n\n return (\n <div className={styles.stack}>\n <OverviewKpis\n data={data}\n loading={loading}\n currency={currency}\n focus={focus}\n compare={compare}\n caption={caption}\n />\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <Panel title=\"GMV & orders\" caption={caption}>\n {focus || loading ? (\n <GmvOrdersChart\n gmvTrend={gmvTrend}\n ordersTrend={ordersTrend}\n previousGmvTrend={[]}\n previousOrdersTrend={[]}\n compare={false}\n span={span}\n money={moneyIn(focus ?? 'USD')}\n />\n ) : (\n pick\n )}\n </Panel>\n </div>\n </div>\n\n <div className=\"adm-grid\">\n <div className={services ? 'adm-span-7' : 'adm-span-12'}>\n {focus || loading ? (\n <TopMerchants\n rows={rowFor(data?.topTenantsByCurrency, focus)?.tenants ?? []}\n loading={loading}\n money={moneyIn(focus ?? 'USD')}\n caption={caption}\n onOpen={onOpenMerchant}\n />\n ) : (\n <Panel title=\"Top merchants by sales\" caption={caption}>\n {pick}\n </Panel>\n )}\n </div>\n {services && (\n <div className=\"adm-span-5\">\n <SalesByService\n rows={services}\n loading={loading}\n money={moneyIn(currencies[0])}\n caption={caption}\n />\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";AAkEM,SAEE,KAFF;AAhEN,SAAS,eAAe;AACxB,SAAS,QAAQ,mBAAmB;AACpC,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,OAAO,YAAY;AAQZ,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAOG;AACD,QAAM,UAAU,mBAAmB;AAAA,IACjC,WAAW,MAAM;AAAA,IACjB,SAAS,MAAM;AAAA,EACjB,CAAC;AACD,QAAM,OAAO,QAAQ;AACrB,QAAM,aAAa,MAAM,cAAc,CAAC;AAExC,QAAM,QAAQ,cAAc,YAAY,QAAQ;AAChD,QAAM,OAAO,aAAa,KAAK;AAC/B,QAAM,SAAS,GAAG,KAAK,CAAC,EAAE,YAAY,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC;AACvD,QAAM,UAAU,QAAQ,GAAG,MAAM,SAAM,KAAK,KAAK;AAEjD,QAAM,SAAS;AAAA,IACb,MAAM,OAAO,MAAM,MAAM,6BAA6B,KAAK,GAAG;AAAA,IAC9D,CAAC,MAAM,KAAK;AAAA,EACd;AACA,QAAM,WAAW,QAAQ,MAAM,QAAQ,QAAQ,KAAK,GAAG,CAAC,MAAM,CAAC;AAC/D,QAAM,cAAc,QAAQ,MAAM,QAAQ,QAAQ,QAAQ,GAAG,CAAC,MAAM,CAAC;AAErE,QAAM,WACJ,MAAM,gBAAgB,UAAU,WAAW,WAAW,IAClD,KAAK,iBACL;AAEN,MAAI,QAAQ,SAAS;AACnB,WACE,qBAAC,eAAY;AAAA;AAAA,MACgC;AAAA,MAC3C,oBAAC,UAAO,MAAK,MAAK,SAAQ,aAAY,SAAS,MAAM,QAAQ,QAAQ,GAAG,uBAExE;AAAA,OACF;AAAA,EAEJ;AAEA,QAAM,UAAU,QAAQ,aAAa,CAAC;AACtC,QAAM,OACJ,oBAAC,OAAE,WAAU,wCAAwC,yBAAc;AAGrE,SACE,qBAAC,SAAI,WAAW,OAAO,OACrB;AAAA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,IAEA,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb,8BAAC,SAAM,OAAM,gBAAe,SACzB,mBAAS,UACR;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,kBAAkB,CAAC;AAAA,QACnB,qBAAqB,CAAC;AAAA,QACtB,SAAS;AAAA,QACT;AAAA,QACA,OAAO,QAAQ,SAAS,KAAK;AAAA;AAAA,IAC/B,IAEA,MAEJ,GACF,GACF;AAAA,IAEA,qBAAC,SAAI,WAAU,YACb;AAAA,0BAAC,SAAI,WAAW,WAAW,eAAe,eACvC,mBAAS,UACR;AAAA,QAAC;AAAA;AAAA,UACC,MAAM,OAAO,MAAM,sBAAsB,KAAK,GAAG,WAAW,CAAC;AAAA,UAC7D;AAAA,UACA,OAAO,QAAQ,SAAS,KAAK;AAAA,UAC7B;AAAA,UACA,QAAQ;AAAA;AAAA,MACV,IAEA,oBAAC,SAAM,OAAM,0BAAyB,SACnC,gBACH,GAEJ;AAAA,MACC,YACC,oBAAC,SAAI,WAAU,cACb;AAAA,QAAC;AAAA;AAAA,UACC,MAAM;AAAA,UACN;AAAA,UACA,OAAO,QAAQ,WAAW,CAAC,CAAC;AAAA,UAC5B;AAAA;AAAA,MACF,GACF;AAAA,OAEJ;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/commerce/commerce-overview.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { Button, ErrorBanner } from '@withpotter/ui-kit';\nimport { usePlatformCockpit } from '../hooks';\nimport {\n GmvOrdersChart,\n PICK_CURRENCY,\n Panel,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rowFor,\n toTrend,\n type DateRange,\n} from '../_ui';\nimport { OverviewKpis } from './overview-kpis';\nimport { SalesByService } from './overview-services';\nimport { TopMerchants } from './overview-top-merchants';\nimport styles from './overview.module.css';\n\n/**\n * Commerce across every merchant, from the one cockpit call: the headline\n * figures, who sells most, where the base sits and who needs a look.\n * Nothing here is fetched per merchant. Money comes back once per currency\n * and is never added across currencies or converted.\n */\nexport function CommerceOverview({\n range,\n currency,\n compare,\n onOpenMerchant,\n}: {\n range: DateRange;\n /** The currency filter's choice; '' shows every currency. */\n currency: string;\n /** False for \"all time\": there is no earlier period to compare with. */\n compare: boolean;\n onOpenMerchant: (id: string) => void;\n}) {\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n const data = cockpit.data;\n const currencies = data?.currencies ?? [];\n // A chart or a ranking draws one currency: the filter's, or the only one.\n const focus = focusCurrency(currencies, currency);\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n const caption = focus ? `${period} · ${focus}` : period;\n\n const series = useMemo(\n () => rowFor(data?.trend.performanceSeriesByCurrency, focus)?.series,\n [data, focus],\n );\n const gmvTrend = useMemo(() => toTrend(series, 'gmv'), [series]);\n const ordersTrend = useMemo(() => toTrend(series, 'orders'), [series]);\n // Sales by service is one mixed figure: only honest with one currency.\n const services =\n data?.salesByService?.length && currencies.length === 1\n ? data.salesByService\n : null;\n\n if (cockpit.isError) {\n return (\n <ErrorBanner>\n The commerce overview could not be loaded.{' '}\n <Button size=\"sm\" variant=\"secondary\" onClick={() => cockpit.refetch()}>\n Try again\n </Button>\n </ErrorBanner>\n );\n }\n\n const loading = cockpit.isLoading || !data;\n const pick = (\n <p className=\"text-sm text-[var(--text-secondary)]\">{PICK_CURRENCY}</p>\n );\n\n return (\n <div className={styles.stack}>\n <OverviewKpis\n data={data}\n loading={loading}\n currency={currency}\n focus={focus}\n compare={compare}\n caption={caption}\n />\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <Panel title=\"GMV & orders\" caption={caption}>\n {focus || loading ? (\n <GmvOrdersChart\n gmvTrend={gmvTrend}\n ordersTrend={ordersTrend}\n previousGmvTrend={[]}\n previousOrdersTrend={[]}\n compare={false}\n span={span}\n money={moneyIn(focus ?? 'NGN')}\n />\n ) : (\n pick\n )}\n </Panel>\n </div>\n </div>\n\n <div className=\"adm-grid\">\n <div className={services ? 'adm-span-7' : 'adm-span-12'}>\n {focus || loading ? (\n <TopMerchants\n rows={rowFor(data?.topTenantsByCurrency, focus)?.tenants ?? []}\n loading={loading}\n money={moneyIn(focus ?? 'NGN')}\n caption={caption}\n onOpen={onOpenMerchant}\n />\n ) : (\n <Panel title=\"Top merchants by sales\" caption={caption}>\n {pick}\n </Panel>\n )}\n </div>\n {services && (\n <div className=\"adm-span-5\">\n <SalesByService\n rows={services}\n loading={loading}\n money={moneyIn(currencies[0])}\n caption={caption}\n />\n </div>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":";AAkEM,SAEE,KAFF;AAhEN,SAAS,eAAe;AACxB,SAAS,QAAQ,mBAAmB;AACpC,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,OAAO,YAAY;AAQZ,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAOG;AACD,QAAM,UAAU,mBAAmB;AAAA,IACjC,WAAW,MAAM;AAAA,IACjB,SAAS,MAAM;AAAA,EACjB,CAAC;AACD,QAAM,OAAO,QAAQ;AACrB,QAAM,aAAa,MAAM,cAAc,CAAC;AAExC,QAAM,QAAQ,cAAc,YAAY,QAAQ;AAChD,QAAM,OAAO,aAAa,KAAK;AAC/B,QAAM,SAAS,GAAG,KAAK,CAAC,EAAE,YAAY,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC;AACvD,QAAM,UAAU,QAAQ,GAAG,MAAM,SAAM,KAAK,KAAK;AAEjD,QAAM,SAAS;AAAA,IACb,MAAM,OAAO,MAAM,MAAM,6BAA6B,KAAK,GAAG;AAAA,IAC9D,CAAC,MAAM,KAAK;AAAA,EACd;AACA,QAAM,WAAW,QAAQ,MAAM,QAAQ,QAAQ,KAAK,GAAG,CAAC,MAAM,CAAC;AAC/D,QAAM,cAAc,QAAQ,MAAM,QAAQ,QAAQ,QAAQ,GAAG,CAAC,MAAM,CAAC;AAErE,QAAM,WACJ,MAAM,gBAAgB,UAAU,WAAW,WAAW,IAClD,KAAK,iBACL;AAEN,MAAI,QAAQ,SAAS;AACnB,WACE,qBAAC,eAAY;AAAA;AAAA,MACgC;AAAA,MAC3C,oBAAC,UAAO,MAAK,MAAK,SAAQ,aAAY,SAAS,MAAM,QAAQ,QAAQ,GAAG,uBAExE;AAAA,OACF;AAAA,EAEJ;AAEA,QAAM,UAAU,QAAQ,aAAa,CAAC;AACtC,QAAM,OACJ,oBAAC,OAAE,WAAU,wCAAwC,yBAAc;AAGrE,SACE,qBAAC,SAAI,WAAW,OAAO,OACrB;AAAA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,IAEA,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb,8BAAC,SAAM,OAAM,gBAAe,SACzB,mBAAS,UACR;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,kBAAkB,CAAC;AAAA,QACnB,qBAAqB,CAAC;AAAA,QACtB,SAAS;AAAA,QACT;AAAA,QACA,OAAO,QAAQ,SAAS,KAAK;AAAA;AAAA,IAC/B,IAEA,MAEJ,GACF,GACF;AAAA,IAEA,qBAAC,SAAI,WAAU,YACb;AAAA,0BAAC,SAAI,WAAW,WAAW,eAAe,eACvC,mBAAS,UACR;AAAA,QAAC;AAAA;AAAA,UACC,MAAM,OAAO,MAAM,sBAAsB,KAAK,GAAG,WAAW,CAAC;AAAA,UAC7D;AAAA,UACA,OAAO,QAAQ,SAAS,KAAK;AAAA,UAC7B;AAAA,UACA,QAAQ;AAAA;AAAA,MACV,IAEA,oBAAC,SAAM,OAAM,0BAAyB,SACnC,gBACH,GAEJ;AAAA,MACC,YACC,oBAAC,SAAI,WAAU,cACb;AAAA,QAAC;AAAA;AAAA,UACC,MAAM;AAAA,UACN;AAAA,UACA,OAAO,QAAQ,WAAW,CAAC,CAAC;AAAA,UAC5B;AAAA;AAAA,MACF,GACF;AAAA,OAEJ;AAAA,KACF;AAEJ;","names":[]}
@@ -3,6 +3,7 @@ const compactMoney = (n, currency) => {
3
3
  try {
4
4
  return new Intl.NumberFormat("en-US", {
5
5
  style: "currency",
6
+ currencyDisplay: "narrowSymbol",
6
7
  currency,
7
8
  notation: "compact",
8
9
  maximumFractionDigits: 1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/commerce/merchant-meta.ts"],"sourcesContent":["import type { TenantRow, TenantStatus } from '@withpotter/lib/admin-api';\nimport type { PillTone } from '../tenants/[id]/commerce/format';\n\n/**\n * GMV is in the merchant's own currency. With the currency known (tenant\n * detail), show it; list rows carry none, so they get a plain number.\n */\nexport const compactMoney = (n: number, currency?: string | null) => {\n if (currency) {\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency,\n notation: 'compact',\n maximumFractionDigits: 1,\n }).format(n ?? 0);\n } catch {\n // An unknown code falls through to the plain number.\n }\n }\n\n return compactNumber(n);\n};\n\n/**\n * Sales per currency, compact and never added together: \"₦1.2M · $3.4K\".\n * Falls back to the plain number when the API sends no per-currency rows.\n */\nexport const compactSales = (\n rows: ReadonlyArray<{ currency: string; amount: number }> | undefined,\n fallback: number,\n) =>\n rows?.length\n ? rows.map((r) => compactMoney(r.amount, r.currency)).join(' · ')\n : compactNumber(fallback);\n\n/** \"12.4K\": sales without a currency, for list rows. */\nexport const compactNumber = (n: number) =>\n new Intl.NumberFormat('en-US', {\n notation: 'compact',\n maximumFractionDigits: 1,\n }).format(n ?? 0);\n\nexport const STATUS_TONE: Record<TenantStatus, PillTone> = {\n active: 'success',\n pending: 'pending',\n suspended: 'failed',\n deleted: 'neutral',\n};\n\n/**\n * A merchant's logo when the API sends one, flat (`logoUrl`) or on the\n * profile (`profile.logoUrl`). Without it EntityAvatar falls back to initials,\n * so every list picks logos up the day the API starts sending them.\n */\nexport const logoOf = (row: object): string | null => {\n const r = row as {\n logoUrl?: string | null;\n profile?: { logoUrl?: string | null } | null;\n };\n\n return r.logoUrl ?? r.profile?.logoUrl ?? null;\n};\n\n/**\n * Ranked by sales within the rows shown only. Each figure is in its own\n * merchant's currency, so this orders a page; it is not a platform league.\n */\nexport const bySales = (rows: TenantRow[]) =>\n [...rows].sort((a, b) => (b.gmv ?? 0) - (a.gmv ?? 0));\n"],"mappings":"AAOO,MAAM,eAAe,CAAC,GAAW,aAA6B;AACnE,MAAI,UAAU;AACZ,QAAI;AACF,aAAO,IAAI,KAAK,aAAa,SAAS;AAAA,QACpC,OAAO;AAAA,QACP;AAAA,QACA,UAAU;AAAA,QACV,uBAAuB;AAAA,MACzB,CAAC,EAAE,OAAO,KAAK,CAAC;AAAA,IAClB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO,cAAc,CAAC;AACxB;AAMO,MAAM,eAAe,CAC1B,MACA,aAEA,MAAM,SACF,KAAK,IAAI,CAAC,MAAM,aAAa,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,KAAK,QAAK,IAC9D,cAAc,QAAQ;AAGrB,MAAM,gBAAgB,CAAC,MAC5B,IAAI,KAAK,aAAa,SAAS;AAAA,EAC7B,UAAU;AAAA,EACV,uBAAuB;AACzB,CAAC,EAAE,OAAO,KAAK,CAAC;AAEX,MAAM,cAA8C;AAAA,EACzD,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,SAAS;AACX;AAOO,MAAM,SAAS,CAAC,QAA+B;AACpD,QAAM,IAAI;AAKV,SAAO,EAAE,WAAW,EAAE,SAAS,WAAW;AAC5C;AAMO,MAAM,UAAU,CAAC,SACtB,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,OAAO,EAAE,OAAO,MAAM,EAAE,OAAO,EAAE;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/commerce/merchant-meta.ts"],"sourcesContent":["import type { TenantRow, TenantStatus } from '@withpotter/lib/admin-api';\nimport type { PillTone } from '../tenants/[id]/commerce/format';\n\n/**\n * GMV is in the merchant's own currency. With the currency known (tenant\n * detail), show it; list rows carry none, so they get a plain number.\n */\nexport const compactMoney = (n: number, currency?: string | null) => {\n if (currency) {\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currencyDisplay: 'narrowSymbol',\n currency,\n notation: 'compact',\n maximumFractionDigits: 1,\n }).format(n ?? 0);\n } catch {\n // An unknown code falls through to the plain number.\n }\n }\n\n return compactNumber(n);\n};\n\n/**\n * Sales per currency, compact and never added together: \"₦1.2M · $3.4K\".\n * Falls back to the plain number when the API sends no per-currency rows.\n */\nexport const compactSales = (\n rows: ReadonlyArray<{ currency: string; amount: number }> | undefined,\n fallback: number,\n) =>\n rows?.length\n ? rows.map((r) => compactMoney(r.amount, r.currency)).join(' · ')\n : compactNumber(fallback);\n\n/** \"12.4K\": sales without a currency, for list rows. */\nexport const compactNumber = (n: number) =>\n new Intl.NumberFormat('en-US', {\n notation: 'compact',\n maximumFractionDigits: 1,\n }).format(n ?? 0);\n\nexport const STATUS_TONE: Record<TenantStatus, PillTone> = {\n active: 'success',\n pending: 'pending',\n suspended: 'failed',\n deleted: 'neutral',\n};\n\n/**\n * A merchant's logo when the API sends one, flat (`logoUrl`) or on the\n * profile (`profile.logoUrl`). Without it EntityAvatar falls back to initials,\n * so every list picks logos up the day the API starts sending them.\n */\nexport const logoOf = (row: object): string | null => {\n const r = row as {\n logoUrl?: string | null;\n profile?: { logoUrl?: string | null } | null;\n };\n\n return r.logoUrl ?? r.profile?.logoUrl ?? null;\n};\n\n/**\n * Ranked by sales within the rows shown only. Each figure is in its own\n * merchant's currency, so this orders a page; it is not a platform league.\n */\nexport const bySales = (rows: TenantRow[]) =>\n [...rows].sort((a, b) => (b.gmv ?? 0) - (a.gmv ?? 0));\n"],"mappings":"AAOO,MAAM,eAAe,CAAC,GAAW,aAA6B;AACnE,MAAI,UAAU;AACZ,QAAI;AACF,aAAO,IAAI,KAAK,aAAa,SAAS;AAAA,QACpC,OAAO;AAAA,QACP,iBAAiB;AAAA,QACjB;AAAA,QACA,UAAU;AAAA,QACV,uBAAuB;AAAA,MACzB,CAAC,EAAE,OAAO,KAAK,CAAC;AAAA,IAClB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO,cAAc,CAAC;AACxB;AAMO,MAAM,eAAe,CAC1B,MACA,aAEA,MAAM,SACF,KAAK,IAAI,CAAC,MAAM,aAAa,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,KAAK,QAAK,IAC9D,cAAc,QAAQ;AAGrB,MAAM,gBAAgB,CAAC,MAC5B,IAAI,KAAK,aAAa,SAAS;AAAA,EAC7B,UAAU;AAAA,EACV,uBAAuB;AACzB,CAAC,EAAE,OAAO,KAAK,CAAC;AAEX,MAAM,cAA8C;AAAA,EACzD,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,SAAS;AACX;AAOO,MAAM,SAAS,CAAC,QAA+B;AACpD,QAAM,IAAI;AAKV,SAAO,EAAE,WAAW,EAAE,SAAS,WAAW;AAC5C;AAMO,MAAM,UAAU,CAAC,SACtB,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,OAAO,EAAE,OAAO,MAAM,EAAE,OAAO,EAAE;","names":[]}
@@ -78,7 +78,7 @@ function MerchantView({ range }) {
78
78
  ] }),
79
79
  /* @__PURE__ */ jsxs("span", { className: styles.sub, children: [
80
80
  "Shown in ",
81
- t.currency || "USD",
81
+ t.currency || "NGN",
82
82
  ", the merchant\u2019s currency"
83
83
  ] })
84
84
  ] })
@@ -90,7 +90,7 @@ function MerchantView({ range }) {
90
90
  MerchantCommerce,
91
91
  {
92
92
  tenantId: id,
93
- currency: t?.currency ?? "USD",
93
+ currency: t?.currency ?? "NGN",
94
94
  range
95
95
  },
96
96
  id
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/commerce/merchant-view.tsx"],"sourcesContent":["'use client';\n\nimport { useState } from 'react';\nimport Link from 'next/link';\nimport { usePathname, useRouter, useSearchParams } from 'next/navigation';\nimport { Button, Card, EmptyNote, ErrorBanner } from '@withpotter/ui-kit';\nimport { usePlatformTenant, usePlatformTenants } from '../hooks';\nimport { EntityAvatar, Reveal } from '../_fx';\nimport { Skeleton, type DateRange } from '../_ui';\nimport { MerchantCommerce } from '../tenants/[id]/commerce';\nimport { StatusPill } from '../tenants/[id]/commerce/status-pill';\nimport { bySales, compactSales, STATUS_TONE } from './merchant-meta';\nimport { MerchantPicker } from './merchant-picker';\nimport styles from './console.module.css';\n\n/** \"By merchant\": pick a merchant, then read their commerce. */\nexport function MerchantView({ range }: { range: DateRange }) {\n const router = useRouter();\n const pathname = usePathname();\n const params = useSearchParams();\n const [picking, setPicking] = useState(false);\n\n const top = usePlatformTenants({ pageSize: 50 });\n const fallback = bySales(top.data?.rows ?? [])[0]?.id ?? null;\n const id = params.get('merchant') || fallback;\n const tenant = usePlatformTenant(id ?? '', {\n startDate: range.startDate,\n endDate: range.endDate,\n });\n const t = tenant.data;\n\n const pick = (next: string) => {\n const qs = new URLSearchParams(params.toString());\n qs.set('merchant', next);\n router.replace(`${pathname}?${qs.toString()}`, { scroll: false });\n setPicking(false);\n };\n\n const noMerchants = !top.isLoading && !top.isError && !id;\n\n return (\n <div className={styles.body}>\n {top.isError && !id ? (\n <ErrorBanner>\n Merchants could not be loaded.{' '}\n <Button size=\"sm\" variant=\"secondary\" onClick={() => top.refetch()}>\n Try again\n </Button>\n </ErrorBanner>\n ) : noMerchants ? (\n <EmptyNote\n well\n action={\n <Button variant=\"secondary\" asChild>\n <Link href=\"/admin/tenants\">Go to merchants</Link>\n </Button>\n }\n >\n Once a business signs up, its items, orders and stock appear here.\n </EmptyNote>\n ) : (\n <>\n <Card>\n <Reveal className={styles.current}>\n {t?.profile?.logoUrl && (\n <EntityAvatar\n name={t.businessName}\n src={t.profile.logoUrl}\n size={40}\n />\n )}\n <div className={styles.currentMain}>\n <span className={styles.eyebrow}>Merchant</span>\n <span className={styles.currentName} title={t?.businessName}>\n {t ? t.businessName : <Skeleton width={160} height={20} />}\n </span>\n {t && (\n <span className={styles.currentMeta}>\n <StatusPill\n status={t.status}\n tone={STATUS_TONE[t.status]}\n />\n <span className={styles.sub}>\n {compactSales(\n t.metrics.byCurrency?.map((r) => ({\n currency: r.currency,\n amount: r.gmv,\n })),\n t.metrics.gmv,\n )}{' '}\n lifetime sales\n </span>\n <span className={styles.sub}>\n Shown in {t.currency || 'USD'}, the merchant’s currency\n </span>\n </span>\n )}\n </div>\n {id && (\n <Button variant=\"ghost\" asChild>\n <Link href={`/admin/tenants/${id}`}>Merchant profile</Link>\n </Button>\n )}\n <Button variant=\"secondary\" onClick={() => setPicking(true)}>\n Change merchant\n </Button>\n </Reveal>\n </Card>\n\n {id && (\n <MerchantCommerce\n key={id}\n tenantId={id}\n currency={t?.currency ?? 'USD'}\n range={range}\n />\n )}\n </>\n )}\n\n <MerchantPicker\n open={picking}\n onOpenChange={setPicking}\n selectedId={id}\n onPick={(row) => pick(row.id)}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/commerce/merchant-view.tsx"],"sourcesContent":["'use client';\n\nimport { useState } from 'react';\nimport Link from 'next/link';\nimport { usePathname, useRouter, useSearchParams } from 'next/navigation';\nimport { Button, Card, EmptyNote, ErrorBanner } from '@withpotter/ui-kit';\nimport { usePlatformTenant, usePlatformTenants } from '../hooks';\nimport { EntityAvatar, Reveal } from '../_fx';\nimport { Skeleton, type DateRange } from '../_ui';\nimport { MerchantCommerce } from '../tenants/[id]/commerce';\nimport { StatusPill } from '../tenants/[id]/commerce/status-pill';\nimport { bySales, compactSales, STATUS_TONE } from './merchant-meta';\nimport { MerchantPicker } from './merchant-picker';\nimport styles from './console.module.css';\n\n/** \"By merchant\": pick a merchant, then read their commerce. */\nexport function MerchantView({ range }: { range: DateRange }) {\n const router = useRouter();\n const pathname = usePathname();\n const params = useSearchParams();\n const [picking, setPicking] = useState(false);\n\n const top = usePlatformTenants({ pageSize: 50 });\n const fallback = bySales(top.data?.rows ?? [])[0]?.id ?? null;\n const id = params.get('merchant') || fallback;\n const tenant = usePlatformTenant(id ?? '', {\n startDate: range.startDate,\n endDate: range.endDate,\n });\n const t = tenant.data;\n\n const pick = (next: string) => {\n const qs = new URLSearchParams(params.toString());\n qs.set('merchant', next);\n router.replace(`${pathname}?${qs.toString()}`, { scroll: false });\n setPicking(false);\n };\n\n const noMerchants = !top.isLoading && !top.isError && !id;\n\n return (\n <div className={styles.body}>\n {top.isError && !id ? (\n <ErrorBanner>\n Merchants could not be loaded.{' '}\n <Button size=\"sm\" variant=\"secondary\" onClick={() => top.refetch()}>\n Try again\n </Button>\n </ErrorBanner>\n ) : noMerchants ? (\n <EmptyNote\n well\n action={\n <Button variant=\"secondary\" asChild>\n <Link href=\"/admin/tenants\">Go to merchants</Link>\n </Button>\n }\n >\n Once a business signs up, its items, orders and stock appear here.\n </EmptyNote>\n ) : (\n <>\n <Card>\n <Reveal className={styles.current}>\n {t?.profile?.logoUrl && (\n <EntityAvatar\n name={t.businessName}\n src={t.profile.logoUrl}\n size={40}\n />\n )}\n <div className={styles.currentMain}>\n <span className={styles.eyebrow}>Merchant</span>\n <span className={styles.currentName} title={t?.businessName}>\n {t ? t.businessName : <Skeleton width={160} height={20} />}\n </span>\n {t && (\n <span className={styles.currentMeta}>\n <StatusPill\n status={t.status}\n tone={STATUS_TONE[t.status]}\n />\n <span className={styles.sub}>\n {compactSales(\n t.metrics.byCurrency?.map((r) => ({\n currency: r.currency,\n amount: r.gmv,\n })),\n t.metrics.gmv,\n )}{' '}\n lifetime sales\n </span>\n <span className={styles.sub}>\n Shown in {t.currency || 'NGN'}, the merchant’s currency\n </span>\n </span>\n )}\n </div>\n {id && (\n <Button variant=\"ghost\" asChild>\n <Link href={`/admin/tenants/${id}`}>Merchant profile</Link>\n </Button>\n )}\n <Button variant=\"secondary\" onClick={() => setPicking(true)}>\n Change merchant\n </Button>\n </Reveal>\n </Card>\n\n {id && (\n <MerchantCommerce\n key={id}\n tenantId={id}\n currency={t?.currency ?? 'NGN'}\n range={range}\n />\n )}\n </>\n )}\n\n <MerchantPicker\n open={picking}\n onOpenChange={setPicking}\n selectedId={id}\n onPick={(row) => pick(row.id)}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -109,8 +109,6 @@
109
109
  }
110
110
 
111
111
  .trend thead th {
112
- position: sticky;
113
- top: 0;
114
112
  background: var(--bg-base);
115
113
  }
116
114
 
@@ -1,8 +1,6 @@
1
1
  /* The merchant preview: their card on a brand-tinted stage. */
2
2
 
3
3
  .frame {
4
- position: sticky;
5
- top: 0;
6
4
  display: grid;
7
5
  gap: 12px;
8
6
  padding: 16px;
@@ -23,7 +23,7 @@ function RevenueKpis({
23
23
  {
24
24
  label: "Monthly recurring revenue (MRR)",
25
25
  value: tracked ? /* @__PURE__ */ jsx(CountUp, { value: recurring.mrr, format: money }) : notTracked,
26
- hint: `Monthly recurring revenue from active merchant billing subscriptions, in ${data.currency || "USD"}. Yearly plans are divided by 12.`,
26
+ hint: `Monthly recurring revenue from active merchant billing subscriptions, in ${data.currency || "NGN"}. Yearly plans are divided by 12.`,
27
27
  sub: tracked ? `${integer.format(recurring.activeSubscriptions)} paying` : noSource
28
28
  }
29
29
  ) }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/revenue-kpis.tsx"],"sourcesContent":["import { ADMIN_NAME } from '../_shell/admin-brand';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal, TONE } from '../_fx';\nimport { MoneyLines, StatCard, rowFor } from '../_ui';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** The four headline figures: recurring revenue first, then merchant GMV. */\nexport function RevenueKpis({\n data,\n money,\n money2,\n currency,\n focus,\n gmvSpark,\n}: {\n data: PlatformRevenue;\n /** Formats recurring revenue, in the currency the API names for it. */\n money: (value: number) => string;\n money2: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n /** The one currency the share and sparkline follow, or null. */\n focus: string | null;\n gmvSpark: number[];\n}) {\n const { recurring } = data;\n // Without a billing source the API sends 0s; show that nothing is measured.\n const tracked = recurring.tracked !== false;\n const notTracked = 'Not tracked';\n const noSource = 'No billing source connected';\n const gmv = rowFor(data.gmvByCurrency, focus);\n const lifetimeShare =\n gmv && gmv.lifetime > 0\n ? `${Math.round((gmv.inRange / gmv.lifetime) * 100)}% of lifetime`\n : undefined;\n\n return (\n <section aria-label=\"Revenue indicators\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Monthly recurring revenue (MRR)\"\n value={\n tracked ? (\n <CountUp value={recurring.mrr} format={money} />\n ) : (\n notTracked\n )\n }\n hint={`Monthly recurring revenue from active merchant billing subscriptions, in ${data.currency || 'USD'}. Yearly plans are divided by 12.`}\n sub={\n tracked\n ? `${integer.format(recurring.activeSubscriptions)} paying`\n : noSource\n }\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Annual recurring revenue (ARR)\"\n value={\n tracked ? (\n <CountUp value={recurring.arr} format={money} />\n ) : (\n notTracked\n )\n }\n hint=\"Annual recurring revenue: what the monthly recurring revenue adds up to over a year if nothing changes (MRR × 12).\"\n sub={tracked ? 'MRR × 12' : noSource}\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average revenue per account (ARPA)\"\n value={\n tracked ? (\n <CountUp value={recurring.arpa} format={money2} />\n ) : (\n notTracked\n )\n }\n hint=\"What the average paying merchant pays each month: monthly recurring revenue divided by the number of paying merchants.\"\n sub={tracked ? 'Per paying account' : noSource}\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Merchant sales (GMV)\"\n value={\n <MoneyLines\n rows={data.gmvByCurrency}\n currency={currency}\n amount={(g) => g.inRange}\n empty=\"No sales yet\"\n />\n }\n href=\"/admin/tenants\"\n sub={lifetimeShare}\n hint={`The merchant sales ${ADMIN_NAME}'s recurring revenue sits on, not ${ADMIN_NAME}'s own income (that's MRR). One line per currency; currencies are never added together or converted.`}\n spark={focus ? gmvSpark : undefined}\n sparkColor={TONE.money.fg}\n />\n </Reveal>\n </section>\n );\n}\n"],"mappings":"AAsCI,SAMU,KANV;AAtCJ,SAAS,kBAAkB;AAE3B,SAAS,SAAS,QAAQ,YAAY;AACtC,SAAS,YAAY,UAAU,cAAc;AAE7C,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAGtC,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAUG;AACD,QAAM,EAAE,UAAU,IAAI;AAEtB,QAAM,UAAU,UAAU,YAAY;AACtC,QAAM,aAAa;AACnB,QAAM,WAAW;AACjB,QAAM,MAAM,OAAO,KAAK,eAAe,KAAK;AAC5C,QAAM,gBACJ,OAAO,IAAI,WAAW,IAClB,GAAG,KAAK,MAAO,IAAI,UAAU,IAAI,WAAY,GAAG,CAAC,kBACjD;AAEN,SACE,qBAAC,aAAQ,cAAW,sBAAqB,WAAU,YACjD;AAAA,wBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAE9C;AAAA,QAGJ,MAAM,4EAA4E,KAAK,YAAY,KAAK;AAAA,QACxG,KACE,UACI,GAAG,QAAQ,OAAO,UAAU,mBAAmB,CAAC,YAChD;AAAA;AAAA,IAER,GACF;AAAA,IACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAE9C;AAAA,QAGJ,MAAK;AAAA,QACL,KAAK,UAAU,gBAAa;AAAA;AAAA,IAC9B,GACF;AAAA,IACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,MAAM,QAAQ,QAAQ,IAEhD;AAAA,QAGJ,MAAK;AAAA,QACL,KAAK,UAAU,uBAAuB;AAAA;AAAA,IACxC,GACF;AAAA,IACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE;AAAA,UAAC;AAAA;AAAA,YACC,MAAM,KAAK;AAAA,YACX;AAAA,YACA,QAAQ,CAAC,MAAM,EAAE;AAAA,YACjB,OAAM;AAAA;AAAA,QACR;AAAA,QAEF,MAAK;AAAA,QACL,KAAK;AAAA,QACL,MAAM,sBAAsB,UAAU,qCAAqC,UAAU;AAAA,QACrF,OAAO,QAAQ,WAAW;AAAA,QAC1B,YAAY,KAAK,MAAM;AAAA;AAAA,IACzB,GACF;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/revenue-kpis.tsx"],"sourcesContent":["import { ADMIN_NAME } from '../_shell/admin-brand';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal, TONE } from '../_fx';\nimport { MoneyLines, StatCard, rowFor } from '../_ui';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** The four headline figures: recurring revenue first, then merchant GMV. */\nexport function RevenueKpis({\n data,\n money,\n money2,\n currency,\n focus,\n gmvSpark,\n}: {\n data: PlatformRevenue;\n /** Formats recurring revenue, in the currency the API names for it. */\n money: (value: number) => string;\n money2: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n /** The one currency the share and sparkline follow, or null. */\n focus: string | null;\n gmvSpark: number[];\n}) {\n const { recurring } = data;\n // Without a billing source the API sends 0s; show that nothing is measured.\n const tracked = recurring.tracked !== false;\n const notTracked = 'Not tracked';\n const noSource = 'No billing source connected';\n const gmv = rowFor(data.gmvByCurrency, focus);\n const lifetimeShare =\n gmv && gmv.lifetime > 0\n ? `${Math.round((gmv.inRange / gmv.lifetime) * 100)}% of lifetime`\n : undefined;\n\n return (\n <section aria-label=\"Revenue indicators\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Monthly recurring revenue (MRR)\"\n value={\n tracked ? (\n <CountUp value={recurring.mrr} format={money} />\n ) : (\n notTracked\n )\n }\n hint={`Monthly recurring revenue from active merchant billing subscriptions, in ${data.currency || 'NGN'}. Yearly plans are divided by 12.`}\n sub={\n tracked\n ? `${integer.format(recurring.activeSubscriptions)} paying`\n : noSource\n }\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Annual recurring revenue (ARR)\"\n value={\n tracked ? (\n <CountUp value={recurring.arr} format={money} />\n ) : (\n notTracked\n )\n }\n hint=\"Annual recurring revenue: what the monthly recurring revenue adds up to over a year if nothing changes (MRR × 12).\"\n sub={tracked ? 'MRR × 12' : noSource}\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average revenue per account (ARPA)\"\n value={\n tracked ? (\n <CountUp value={recurring.arpa} format={money2} />\n ) : (\n notTracked\n )\n }\n hint=\"What the average paying merchant pays each month: monthly recurring revenue divided by the number of paying merchants.\"\n sub={tracked ? 'Per paying account' : noSource}\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Merchant sales (GMV)\"\n value={\n <MoneyLines\n rows={data.gmvByCurrency}\n currency={currency}\n amount={(g) => g.inRange}\n empty=\"No sales yet\"\n />\n }\n href=\"/admin/tenants\"\n sub={lifetimeShare}\n hint={`The merchant sales ${ADMIN_NAME}'s recurring revenue sits on, not ${ADMIN_NAME}'s own income (that's MRR). One line per currency; currencies are never added together or converted.`}\n spark={focus ? gmvSpark : undefined}\n sparkColor={TONE.money.fg}\n />\n </Reveal>\n </section>\n );\n}\n"],"mappings":"AAsCI,SAMU,KANV;AAtCJ,SAAS,kBAAkB;AAE3B,SAAS,SAAS,QAAQ,YAAY;AACtC,SAAS,YAAY,UAAU,cAAc;AAE7C,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAGtC,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAUG;AACD,QAAM,EAAE,UAAU,IAAI;AAEtB,QAAM,UAAU,UAAU,YAAY;AACtC,QAAM,aAAa;AACnB,QAAM,WAAW;AACjB,QAAM,MAAM,OAAO,KAAK,eAAe,KAAK;AAC5C,QAAM,gBACJ,OAAO,IAAI,WAAW,IAClB,GAAG,KAAK,MAAO,IAAI,UAAU,IAAI,WAAY,GAAG,CAAC,kBACjD;AAEN,SACE,qBAAC,aAAQ,cAAW,sBAAqB,WAAU,YACjD;AAAA,wBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAE9C;AAAA,QAGJ,MAAM,4EAA4E,KAAK,YAAY,KAAK;AAAA,QACxG,KACE,UACI,GAAG,QAAQ,OAAO,UAAU,mBAAmB,CAAC,YAChD;AAAA;AAAA,IAER,GACF;AAAA,IACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAE9C;AAAA,QAGJ,MAAK;AAAA,QACL,KAAK,UAAU,gBAAa;AAAA;AAAA,IAC9B,GACF;AAAA,IACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,MAAM,QAAQ,QAAQ,IAEhD;AAAA,QAGJ,MAAK;AAAA,QACL,KAAK,UAAU,uBAAuB;AAAA;AAAA,IACxC,GACF;AAAA,IACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE;AAAA,UAAC;AAAA;AAAA,YACC,MAAM,KAAK;AAAA,YACX;AAAA,YACA,QAAQ,CAAC,MAAM,EAAE;AAAA,YACjB,OAAM;AAAA;AAAA,QACR;AAAA,QAEF,MAAK;AAAA,QACL,KAAK;AAAA,QACL,MAAM,sBAAsB,UAAU,qCAAqC,UAAU;AAAA,QACrF,OAAO,QAAQ,WAAW;AAAA,QAC1B,YAAY,KAAK,MAAM;AAAA;AAAA,IACzB,GACF;AAAA,KACF;AAEJ;","names":[]}
@@ -50,7 +50,7 @@ function RevenueWorkspace() {
50
50
  });
51
51
  const currencies = useMemo(() => data?.currencies ?? [], [data]);
52
52
  const focus = focusCurrency(currencies, currency);
53
- const recurringCurrency = data?.currency || "USD";
53
+ const recurringCurrency = data?.currency || "NGN";
54
54
  const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);
55
55
  const money2 = useMemo(
56
56
  () => moneyIn(recurringCurrency, 2),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/revenue-workspace.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { ADMIN_NAME } from '../_shell/admin-brand';\nimport { usePlatformCockpit, usePlatformRevenue } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport {\n AdmLoading,\n CurrencyFilter,\n PageHeader,\n PageTabs,\n PeriodSelector,\n customRange,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rangeFromKey,\n useCurrencyParam,\n usePersistedDateRange,\n useTabParam,\n} from '../_ui';\nimport { RevenueOverview } from './revenue-overview';\nimport { RevenueRetention } from './revenue-retention';\n\nconst TABS = [\n { id: 'overview', label: 'Overview' },\n { id: 'retention', label: 'Retention' },\n] as const;\n\nconst KEYS = TABS.map((t) => t.id);\n\n/** Revenue: headline money on Overview, revenue quality and cohorts on Retention. */\nexport function RevenueWorkspace() {\n const [tab, setTab] = useTabParam(KEYS);\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n } = usePersistedDateRange();\n // Merchant GMV comes back once per currency; `?currency=` narrows it.\n const [currency, setCurrency] = useCurrencyParam();\n\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [rangeKey, customStart, customEnd],\n );\n\n const { data, isLoading, isError, refetch } = usePlatformRevenue({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n // Revenue health (MRR movement, logo churn, concentration) is computed as\n // part of the cockpit, not the revenue endpoint — fetched here too rather\n // than duplicating that aggregation.\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n const currencies = useMemo(() => data?.currencies ?? [], [data]);\n const focus = focusCurrency(currencies, currency);\n // Recurring revenue is the platform's own billing, in the currency the API\n // names for it (`currency`), shown as it comes, never converted.\n const recurringCurrency = data?.currency || 'USD';\n const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);\n const money2 = useMemo(\n () => moneyIn(recurringCurrency, 2),\n [recurringCurrency],\n );\n\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n\n let body;\n if (isError) {\n body = (\n <LoadFailed\n message=\"Revenue for this period could not be loaded.\"\n onRetry={() => void refetch()}\n />\n );\n } else if (isLoading || !data) {\n body = <AdmLoading label=\"Loading revenue metrics…\" />;\n } else if (tab === 'retention') {\n body = (\n <RevenueRetention\n quality={data.quality}\n money={money}\n currency={currency}\n caption={currency ? `${period} · ${currency}` : period}\n />\n );\n } else {\n body = (\n <RevenueOverview\n data={data}\n health={cockpit.data?.health}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n period={period}\n span={span}\n recurringCaption={`${period} · ${recurringCurrency}`}\n />\n );\n }\n\n return (\n <div>\n <PageHeader\n title=\"Revenue\"\n description={`Every revenue on ${ADMIN_NAME} is reported here: its subscription income each month and year, and the merchant sales (GMV) it is based on.`}\n actions={\n <div className=\"adm-toolbar\">\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n <PageTabs tabs={TABS} active={tab} onChange={setTab} />\n {body}\n </div>\n );\n}\n"],"mappings":";AAiFM,cAsCI,YAtCJ;AA/EN,SAAS,eAAe;AACxB,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB,0BAA0B;AACvD,SAAS,kBAAkB;AAC3B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,wBAAwB;AAEjC,MAAM,OAAO;AAAA,EACX,EAAE,IAAI,YAAY,OAAO,WAAW;AAAA,EACpC,EAAE,IAAI,aAAa,OAAO,YAAY;AACxC;AAEA,MAAM,OAAO,KAAK,IAAI,CAAC,MAAM,EAAE,EAAE;AAG1B,SAAS,mBAAmB;AACjC,QAAM,CAAC,KAAK,MAAM,IAAI,YAAY,IAAI;AACtC,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI,sBAAsB;AAE1B,QAAM,CAAC,UAAU,WAAW,IAAI,iBAAiB;AAEjD,QAAM,QAAQ;AAAA,IACZ,MACE,aAAa,WACT,YAAY,aAAa,SAAS,IAClC,aAAa,QAAQ;AAAA,IAC3B,CAAC,UAAU,aAAa,SAAS;AAAA,EACnC;AAEA,QAAM,EAAE,MAAM,WAAW,SAAS,QAAQ,IAAI,mBAAmB;AAAA,IAC/D,WAAW,MAAM;AAAA,IACjB,SAAS,MAAM;AAAA,EACjB,CAAC;AAID,QAAM,UAAU,mBAAmB;AAAA,IACjC,WAAW,MAAM;AAAA,IACjB,SAAS,MAAM;AAAA,EACjB,CAAC;AAED,QAAM,aAAa,QAAQ,MAAM,MAAM,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC;AAC/D,QAAM,QAAQ,cAAc,YAAY,QAAQ;AAGhD,QAAM,oBAAoB,MAAM,YAAY;AAC5C,QAAM,QAAQ,QAAQ,MAAM,QAAQ,iBAAiB,GAAG,CAAC,iBAAiB,CAAC;AAC3E,QAAM,SAAS;AAAA,IACb,MAAM,QAAQ,mBAAmB,CAAC;AAAA,IAClC,CAAC,iBAAiB;AAAA,EACpB;AAEA,QAAM,OAAO,aAAa,KAAK;AAC/B,QAAM,SAAS,GAAG,KAAK,CAAC,EAAE,YAAY,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC;AAEvD,MAAI;AACJ,MAAI,SAAS;AACX,WACE;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,SAAS,MAAM,KAAK,QAAQ;AAAA;AAAA,IAC9B;AAAA,EAEJ,WAAW,aAAa,CAAC,MAAM;AAC7B,WAAO,oBAAC,cAAW,OAAM,iCAA2B;AAAA,EACtD,WAAW,QAAQ,aAAa;AAC9B,WACE;AAAA,MAAC;AAAA;AAAA,QACC,SAAS,KAAK;AAAA,QACd;AAAA,QACA;AAAA,QACA,SAAS,WAAW,GAAG,MAAM,SAAM,QAAQ,KAAK;AAAA;AAAA,IAClD;AAAA,EAEJ,OAAO;AACL,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,QAAQ,QAAQ,MAAM;AAAA,QACtB;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,kBAAkB,GAAG,MAAM,SAAM,iBAAiB;AAAA;AAAA,IACpD;AAAA,EAEJ;AAEA,SACE,qBAAC,SACC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,aAAa,oBAAoB,UAAU;AAAA,QAC3C,SACE,qBAAC,SAAI,WAAU,eACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,UAAU;AAAA,cACV;AAAA;AAAA,UACF;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,UAAU;AAAA,cACV;AAAA,cACA;AAAA,cACA;AAAA;AAAA,UACF;AAAA,WACF;AAAA;AAAA,IAEJ;AAAA,IACA,oBAAC,YAAS,MAAM,MAAM,QAAQ,KAAK,UAAU,QAAQ;AAAA,IACpD;AAAA,KACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/revenue-workspace.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { ADMIN_NAME } from '../_shell/admin-brand';\nimport { usePlatformCockpit, usePlatformRevenue } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport {\n AdmLoading,\n CurrencyFilter,\n PageHeader,\n PageTabs,\n PeriodSelector,\n customRange,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rangeFromKey,\n useCurrencyParam,\n usePersistedDateRange,\n useTabParam,\n} from '../_ui';\nimport { RevenueOverview } from './revenue-overview';\nimport { RevenueRetention } from './revenue-retention';\n\nconst TABS = [\n { id: 'overview', label: 'Overview' },\n { id: 'retention', label: 'Retention' },\n] as const;\n\nconst KEYS = TABS.map((t) => t.id);\n\n/** Revenue: headline money on Overview, revenue quality and cohorts on Retention. */\nexport function RevenueWorkspace() {\n const [tab, setTab] = useTabParam(KEYS);\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n } = usePersistedDateRange();\n // Merchant GMV comes back once per currency; `?currency=` narrows it.\n const [currency, setCurrency] = useCurrencyParam();\n\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [rangeKey, customStart, customEnd],\n );\n\n const { data, isLoading, isError, refetch } = usePlatformRevenue({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n // Revenue health (MRR movement, logo churn, concentration) is computed as\n // part of the cockpit, not the revenue endpoint — fetched here too rather\n // than duplicating that aggregation.\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n const currencies = useMemo(() => data?.currencies ?? [], [data]);\n const focus = focusCurrency(currencies, currency);\n // Recurring revenue is the platform's own billing, in the currency the API\n // names for it (`currency`), shown as it comes, never converted.\n const recurringCurrency = data?.currency || 'NGN';\n const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);\n const money2 = useMemo(\n () => moneyIn(recurringCurrency, 2),\n [recurringCurrency],\n );\n\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n\n let body;\n if (isError) {\n body = (\n <LoadFailed\n message=\"Revenue for this period could not be loaded.\"\n onRetry={() => void refetch()}\n />\n );\n } else if (isLoading || !data) {\n body = <AdmLoading label=\"Loading revenue metrics…\" />;\n } else if (tab === 'retention') {\n body = (\n <RevenueRetention\n quality={data.quality}\n money={money}\n currency={currency}\n caption={currency ? `${period} · ${currency}` : period}\n />\n );\n } else {\n body = (\n <RevenueOverview\n data={data}\n health={cockpit.data?.health}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n period={period}\n span={span}\n recurringCaption={`${period} · ${recurringCurrency}`}\n />\n );\n }\n\n return (\n <div>\n <PageHeader\n title=\"Revenue\"\n description={`Every revenue on ${ADMIN_NAME} is reported here: its subscription income each month and year, and the merchant sales (GMV) it is based on.`}\n actions={\n <div className=\"adm-toolbar\">\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n <PageTabs tabs={TABS} active={tab} onChange={setTab} />\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -75,11 +75,8 @@
75
75
  outline-offset: -2px;
76
76
  }
77
77
 
78
- /* Sticky column header with a hairline, not a shadow. */
78
+ /* Column header with a hairline, not a shadow. */
79
79
  .scroll thead th {
80
- position: sticky;
81
- top: 0;
82
- z-index: 1;
83
80
  background: var(--bg-base);
84
81
  box-shadow: inset 0 -1px 0 var(--border-subtle);
85
82
  white-space: nowrap;
@@ -3,7 +3,8 @@ function moneyIn(currency, decimals = 0) {
3
3
  try {
4
4
  return new Intl.NumberFormat("en-US", {
5
5
  style: "currency",
6
- currency: currency || "USD",
6
+ currencyDisplay: "narrowSymbol",
7
+ currency: currency || "NGN",
7
8
  maximumFractionDigits: decimals
8
9
  }).format(n);
9
10
  } catch {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../src/admin/tenants/%5Bid%5D/commerce/format.ts"],"sourcesContent":["import type { TenantProduct } from '@withpotter/lib/admin-api';\n\n/** Amounts in the merchant's own currency, whole units for headlines. */\nexport function moneyIn(currency: string, decimals = 0) {\n return (n: number) => {\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currency: currency || 'USD',\n maximumFractionDigits: decimals,\n }).format(n);\n } catch {\n return `${currency} ${Math.round(n).toLocaleString()}`;\n }\n };\n}\n\nexport type Money = (n: number) => string;\n\n/** 1.2k / 3.4m, for figures in tight tiles. */\nexport const compact = (n: number) =>\n new Intl.NumberFormat('en-US', {\n notation: 'compact',\n maximumFractionDigits: 1,\n }).format(n);\n\nexport const shortDate = (iso: string) =>\n new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' }).format(\n new Date(iso),\n );\n\n/** Sentence-case a machine word: \"checked_in\" → \"Checked in\". */\nexport const humanize = (s: string) => {\n const t = s.replace(/[_-]+/g, ' ').trim();\n return t ? t[0].toUpperCase() + t.slice(1) : '–';\n};\n\nexport type PillTone =\n 'success' | 'pending' | 'failed' | 'processing' | 'neutral';\n\nconst TONE_OF: Record<string, PillTone> = {\n completed: 'success',\n paid: 'success',\n success: 'success',\n settled: 'success',\n fulfilled: 'success',\n delivered: 'success',\n collected: 'success',\n pending: 'pending',\n unpaid: 'pending',\n awaiting_confirmation: 'pending',\n authorized: 'pending',\n unfulfilled: 'pending',\n partial: 'pending',\n confirmed: 'processing',\n processing: 'processing',\n checked_in: 'processing',\n ready: 'processing',\n failed: 'failed',\n reversed: 'failed',\n expired: 'failed',\n cancelled: 'neutral',\n refunded: 'neutral',\n partially_refunded: 'neutral',\n};\n\nexport const toneOf = (status: string): PillTone =>\n TONE_OF[status?.toLowerCase()] ?? 'neutral';\n\nexport type StockState = {\n label: string;\n tone: PillTone;\n available: number | null;\n};\n\n/** What a shelf looks like for one item, from the counts the API sends. */\nexport function stockOf(p: TenantProduct): StockState {\n if (!p.trackInventory || p.quantity == null)\n return { label: 'Not tracked', tone: 'neutral', available: null };\n\n const available = p.quantity - (p.reserved ?? 0);\n\n if (available <= 0)\n return { label: 'Out of stock', tone: 'failed', available };\n\n if (p.lowStockThreshold != null && available <= p.lowStockThreshold)\n return { label: `Low · ${available}`, tone: 'pending', available };\n\n return {\n label: `In stock · ${compact(available)}`,\n tone: 'success',\n available,\n };\n}\n"],"mappings":"AAGO,SAAS,QAAQ,UAAkB,WAAW,GAAG;AACtD,SAAO,CAAC,MAAc;AACpB,QAAI;AACF,aAAO,IAAI,KAAK,aAAa,SAAS;AAAA,QACpC,OAAO;AAAA,QACP,UAAU,YAAY;AAAA,QACtB,uBAAuB;AAAA,MACzB,CAAC,EAAE,OAAO,CAAC;AAAA,IACb,QAAQ;AACN,aAAO,GAAG,QAAQ,IAAI,KAAK,MAAM,CAAC,EAAE,eAAe,CAAC;AAAA,IACtD;AAAA,EACF;AACF;AAKO,MAAM,UAAU,CAAC,MACtB,IAAI,KAAK,aAAa,SAAS;AAAA,EAC7B,UAAU;AAAA,EACV,uBAAuB;AACzB,CAAC,EAAE,OAAO,CAAC;AAEN,MAAM,YAAY,CAAC,QACxB,IAAI,KAAK,eAAe,MAAM,EAAE,OAAO,SAAS,KAAK,UAAU,CAAC,EAAE;AAAA,EAChE,IAAI,KAAK,GAAG;AACd;AAGK,MAAM,WAAW,CAAC,MAAc;AACrC,QAAM,IAAI,EAAE,QAAQ,UAAU,GAAG,EAAE,KAAK;AACxC,SAAO,IAAI,EAAE,CAAC,EAAE,YAAY,IAAI,EAAE,MAAM,CAAC,IAAI;AAC/C;AAKA,MAAM,UAAoC;AAAA,EACxC,WAAW;AAAA,EACX,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,uBAAuB;AAAA,EACvB,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU;AAAA,EACV,oBAAoB;AACtB;AAEO,MAAM,SAAS,CAAC,WACrB,QAAQ,QAAQ,YAAY,CAAC,KAAK;AAS7B,SAAS,QAAQ,GAA8B;AACpD,MAAI,CAAC,EAAE,kBAAkB,EAAE,YAAY;AACrC,WAAO,EAAE,OAAO,eAAe,MAAM,WAAW,WAAW,KAAK;AAElE,QAAM,YAAY,EAAE,YAAY,EAAE,YAAY;AAE9C,MAAI,aAAa;AACf,WAAO,EAAE,OAAO,gBAAgB,MAAM,UAAU,UAAU;AAE5D,MAAI,EAAE,qBAAqB,QAAQ,aAAa,EAAE;AAChD,WAAO,EAAE,OAAO,YAAS,SAAS,IAAI,MAAM,WAAW,UAAU;AAEnE,SAAO;AAAA,IACL,OAAO,iBAAc,QAAQ,SAAS,CAAC;AAAA,IACvC,MAAM;AAAA,IACN;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../../../src/admin/tenants/%5Bid%5D/commerce/format.ts"],"sourcesContent":["import type { TenantProduct } from '@withpotter/lib/admin-api';\n\n/** Amounts in the merchant's own currency, whole units for headlines. */\nexport function moneyIn(currency: string, decimals = 0) {\n return (n: number) => {\n try {\n return new Intl.NumberFormat('en-US', {\n style: 'currency',\n currencyDisplay: 'narrowSymbol',\n currency: currency || 'NGN',\n maximumFractionDigits: decimals,\n }).format(n);\n } catch {\n return `${currency} ${Math.round(n).toLocaleString()}`;\n }\n };\n}\n\nexport type Money = (n: number) => string;\n\n/** 1.2k / 3.4m, for figures in tight tiles. */\nexport const compact = (n: number) =>\n new Intl.NumberFormat('en-US', {\n notation: 'compact',\n maximumFractionDigits: 1,\n }).format(n);\n\nexport const shortDate = (iso: string) =>\n new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' }).format(\n new Date(iso),\n );\n\n/** Sentence-case a machine word: \"checked_in\" → \"Checked in\". */\nexport const humanize = (s: string) => {\n const t = s.replace(/[_-]+/g, ' ').trim();\n return t ? t[0].toUpperCase() + t.slice(1) : '–';\n};\n\nexport type PillTone =\n 'success' | 'pending' | 'failed' | 'processing' | 'neutral';\n\nconst TONE_OF: Record<string, PillTone> = {\n completed: 'success',\n paid: 'success',\n success: 'success',\n settled: 'success',\n fulfilled: 'success',\n delivered: 'success',\n collected: 'success',\n pending: 'pending',\n unpaid: 'pending',\n awaiting_confirmation: 'pending',\n authorized: 'pending',\n unfulfilled: 'pending',\n partial: 'pending',\n confirmed: 'processing',\n processing: 'processing',\n checked_in: 'processing',\n ready: 'processing',\n failed: 'failed',\n reversed: 'failed',\n expired: 'failed',\n cancelled: 'neutral',\n refunded: 'neutral',\n partially_refunded: 'neutral',\n};\n\nexport const toneOf = (status: string): PillTone =>\n TONE_OF[status?.toLowerCase()] ?? 'neutral';\n\nexport type StockState = {\n label: string;\n tone: PillTone;\n available: number | null;\n};\n\n/** What a shelf looks like for one item, from the counts the API sends. */\nexport function stockOf(p: TenantProduct): StockState {\n if (!p.trackInventory || p.quantity == null)\n return { label: 'Not tracked', tone: 'neutral', available: null };\n\n const available = p.quantity - (p.reserved ?? 0);\n\n if (available <= 0)\n return { label: 'Out of stock', tone: 'failed', available };\n\n if (p.lowStockThreshold != null && available <= p.lowStockThreshold)\n return { label: `Low · ${available}`, tone: 'pending', available };\n\n return {\n label: `In stock · ${compact(available)}`,\n tone: 'success',\n available,\n };\n}\n"],"mappings":"AAGO,SAAS,QAAQ,UAAkB,WAAW,GAAG;AACtD,SAAO,CAAC,MAAc;AACpB,QAAI;AACF,aAAO,IAAI,KAAK,aAAa,SAAS;AAAA,QACpC,OAAO;AAAA,QACP,iBAAiB;AAAA,QACjB,UAAU,YAAY;AAAA,QACtB,uBAAuB;AAAA,MACzB,CAAC,EAAE,OAAO,CAAC;AAAA,IACb,QAAQ;AACN,aAAO,GAAG,QAAQ,IAAI,KAAK,MAAM,CAAC,EAAE,eAAe,CAAC;AAAA,IACtD;AAAA,EACF;AACF;AAKO,MAAM,UAAU,CAAC,MACtB,IAAI,KAAK,aAAa,SAAS;AAAA,EAC7B,UAAU;AAAA,EACV,uBAAuB;AACzB,CAAC,EAAE,OAAO,CAAC;AAEN,MAAM,YAAY,CAAC,QACxB,IAAI,KAAK,eAAe,MAAM,EAAE,OAAO,SAAS,KAAK,UAAU,CAAC,EAAE;AAAA,EAChE,IAAI,KAAK,GAAG;AACd;AAGK,MAAM,WAAW,CAAC,MAAc;AACrC,QAAM,IAAI,EAAE,QAAQ,UAAU,GAAG,EAAE,KAAK;AACxC,SAAO,IAAI,EAAE,CAAC,EAAE,YAAY,IAAI,EAAE,MAAM,CAAC,IAAI;AAC/C;AAKA,MAAM,UAAoC;AAAA,EACxC,WAAW;AAAA,EACX,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,uBAAuB;AAAA,EACvB,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU;AAAA,EACV,oBAAoB;AACtB;AAEO,MAAM,SAAS,CAAC,WACrB,QAAQ,QAAQ,YAAY,CAAC,KAAK;AAS7B,SAAS,QAAQ,GAA8B;AACpD,MAAI,CAAC,EAAE,kBAAkB,EAAE,YAAY;AACrC,WAAO,EAAE,OAAO,eAAe,MAAM,WAAW,WAAW,KAAK;AAElE,QAAM,YAAY,EAAE,YAAY,EAAE,YAAY;AAE9C,MAAI,aAAa;AACf,WAAO,EAAE,OAAO,gBAAgB,MAAM,UAAU,UAAU;AAE5D,MAAI,EAAE,qBAAqB,QAAQ,aAAa,EAAE;AAChD,WAAO,EAAE,OAAO,YAAS,SAAS,IAAI,MAAM,WAAW,UAAU;AAEnE,SAAO;AAAA,IACL,OAAO,iBAAc,QAAQ,SAAS,CAAC;AAAA,IACvC,MAAM;AAAA,IACN;AAAA,EACF;AACF;","names":[]}