@carlonicora/nextjs-jsonapi 6.10.2 → 6.12.0

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 (113) hide show
  1. package/dist/{BlockNoteEditor-LX7NMBV6.js → BlockNoteEditor-R2SLOYS6.js} +84 -43
  2. package/dist/BlockNoteEditor-R2SLOYS6.js.map +1 -0
  3. package/dist/{BlockNoteEditor-7GQWHXBC.mjs → BlockNoteEditor-ZJPGGUQQ.mjs} +61 -20
  4. package/dist/BlockNoteEditor-ZJPGGUQQ.mjs.map +1 -0
  5. package/dist/{BlockNoteViewer-NXV5PKR4.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
  6. package/dist/{BlockNoteViewer-NXV5PKR4.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
  7. package/dist/{BlockNoteViewer-D3Y4FUST.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
  8. package/dist/billing/index.js +491 -491
  9. package/dist/billing/index.mjs +3 -3
  10. package/dist/{chunk-QB2XU64X.mjs → chunk-6RLATJ2L.mjs} +2 -2
  11. package/dist/{chunk-62P4KIR2.mjs → chunk-BGLOFZ3K.mjs} +282 -2
  12. package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
  13. package/dist/{chunk-3I5JCFPY.js → chunk-BXR36J25.js} +1647 -969
  14. package/dist/chunk-BXR36J25.js.map +1 -0
  15. package/dist/{chunk-62VVBUIL.js → chunk-FNPXCYHO.js} +8 -8
  16. package/dist/{chunk-62VVBUIL.js.map → chunk-FNPXCYHO.js.map} +1 -1
  17. package/dist/{chunk-TOPGUZSX.js → chunk-OHJSI6L2.js} +4 -4
  18. package/dist/{chunk-TOPGUZSX.js.map → chunk-OHJSI6L2.js.map} +1 -1
  19. package/dist/{chunk-LDUTS7SJ.mjs → chunk-SQUMPLNN.mjs} +1281 -603
  20. package/dist/chunk-SQUMPLNN.mjs.map +1 -0
  21. package/dist/{chunk-Z4OVEA3N.js → chunk-YGEOX2XX.js} +283 -3
  22. package/dist/chunk-YGEOX2XX.js.map +1 -0
  23. package/dist/{chunk-GHAZOQTT.mjs → chunk-YP6W4AB5.mjs} +2 -2
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.d.mts +88 -29
  27. package/dist/components/index.d.ts +88 -29
  28. package/dist/components/index.js +10 -4
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/index.mjs +9 -3
  31. package/dist/contexts/index.js +4 -4
  32. package/dist/contexts/index.mjs +3 -3
  33. package/dist/core/index.d.mts +159 -3
  34. package/dist/core/index.d.ts +159 -3
  35. package/dist/core/index.js +14 -2
  36. package/dist/core/index.js.map +1 -1
  37. package/dist/core/index.mjs +13 -1
  38. package/dist/email-automations/index.js +93 -93
  39. package/dist/email-automations/index.mjs +3 -3
  40. package/dist/features/analytics/index.js +120 -122
  41. package/dist/features/analytics/index.js.map +1 -1
  42. package/dist/features/analytics/index.mjs +7 -9
  43. package/dist/features/analytics/index.mjs.map +1 -1
  44. package/dist/features/analytics/server.js +4 -4
  45. package/dist/features/analytics/server.mjs +2 -2
  46. package/dist/features/blog/index.js +150 -150
  47. package/dist/features/blog/index.mjs +3 -3
  48. package/dist/features/blog/server.js +16 -16
  49. package/dist/features/blog/server.mjs +1 -1
  50. package/dist/features/help/index.js +37 -37
  51. package/dist/features/help/index.mjs +3 -3
  52. package/dist/features/tokenusage/index.js +126 -128
  53. package/dist/features/tokenusage/index.js.map +1 -1
  54. package/dist/features/tokenusage/index.mjs +14 -16
  55. package/dist/features/tokenusage/index.mjs.map +1 -1
  56. package/dist/index.d.mts +2 -2
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +15 -3
  59. package/dist/index.js.map +1 -1
  60. package/dist/index.mjs +14 -2
  61. package/dist/{HandbookSectionInterface-5I57rC1b.d.mts → referral-admin.interface-C87ApGMR.d.mts} +41 -1
  62. package/dist/{HandbookSectionInterface-IDYEzQMi.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +41 -1
  63. package/dist/server/index.js +3 -3
  64. package/dist/server/index.mjs +2 -2
  65. package/messages/en.json +78 -1
  66. package/messages/it.json +78 -1
  67. package/package.json +8 -3
  68. package/src/components/editors/BlockNoteEditor.tsx +52 -20
  69. package/src/components/editors/__tests__/blocknote-editor-options.spec.ts +81 -0
  70. package/src/components/editors/blockNoteEditorOptions.ts +58 -0
  71. package/src/core/index.ts +2 -0
  72. package/src/core/registry/ModuleRegistry.ts +5 -0
  73. package/src/features/referral/__tests__/referral-admin-models.spec.ts +122 -0
  74. package/src/features/referral/__tests__/referral-admin-service.spec.ts +70 -0
  75. package/src/features/referral/__tests__/referral-code-capture.spec.tsx +105 -0
  76. package/src/features/referral/__tests__/referral-invite-errors.spec.tsx +82 -0
  77. package/src/features/referral/__tests__/referral-visit-service.spec.ts +47 -0
  78. package/src/features/referral/__tests__/referrals-admin-container.spec.tsx +230 -0
  79. package/src/features/referral/components/ReferralCodeCapture.tsx +3 -0
  80. package/src/features/referral/components/ReferralDialog.tsx +9 -13
  81. package/src/features/referral/components/ReferralWidget.tsx +75 -45
  82. package/src/features/referral/components/ReferralsAdminFunnel.tsx +74 -0
  83. package/src/features/referral/components/ReferralsAdminTiles.tsx +128 -0
  84. package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +205 -0
  85. package/src/features/referral/components/index.ts +3 -0
  86. package/src/features/referral/data/ReferralAdminReferral.ts +65 -0
  87. package/src/features/referral/data/ReferralAdminReferrer.ts +65 -0
  88. package/src/features/referral/data/ReferralAdminService.ts +51 -0
  89. package/src/features/referral/data/ReferralAdminSummary.ts +60 -0
  90. package/src/features/referral/data/ReferralService.ts +20 -0
  91. package/src/features/referral/data/ReferralVisit.ts +23 -0
  92. package/src/features/referral/data/index.ts +5 -0
  93. package/src/features/referral/hooks/index.ts +2 -0
  94. package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +127 -0
  95. package/src/features/referral/hooks/useReferralAdminReferrerTableStructure.tsx +229 -0
  96. package/src/features/referral/hooks/useReferralInvite.ts +2 -0
  97. package/src/features/referral/index.ts +1 -0
  98. package/src/features/referral/interfaces/index.ts +1 -0
  99. package/src/features/referral/interfaces/referral-admin.interface.ts +45 -0
  100. package/src/features/referral/referral-admin.modules.ts +32 -0
  101. package/dist/BlockNoteEditor-7GQWHXBC.mjs.map +0 -1
  102. package/dist/BlockNoteEditor-LX7NMBV6.js.map +0 -1
  103. package/dist/chunk-3I5JCFPY.js.map +0 -1
  104. package/dist/chunk-62P4KIR2.mjs.map +0 -1
  105. package/dist/chunk-D6UX37IA.mjs +0 -115
  106. package/dist/chunk-D6UX37IA.mjs.map +0 -1
  107. package/dist/chunk-KEHP2OBQ.js +0 -115
  108. package/dist/chunk-KEHP2OBQ.js.map +0 -1
  109. package/dist/chunk-LDUTS7SJ.mjs.map +0 -1
  110. package/dist/chunk-Z4OVEA3N.js.map +0 -1
  111. /package/dist/{BlockNoteViewer-D3Y4FUST.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
  112. /package/dist/{chunk-QB2XU64X.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
  113. /package/dist/{chunk-GHAZOQTT.mjs.map → chunk-YP6W4AB5.mjs.map} +0 -0
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useUsageFormatters } from "../../tokenusage/lib/formatters";
5
+ import { SEQUENTIAL_RAMP } from "../../tokenusage/lib/palette";
6
+ import { ReferralAdminSummaryInterface } from "../interfaces/referral-admin.interface";
7
+
8
+ type Props = {
9
+ summary: ReferralAdminSummaryInterface[];
10
+ };
11
+
12
+ /**
13
+ * "From visit to paying": visits → invites → sign-ups → paying of the current
14
+ * window as plain proportional bars, each with its share of the previous step.
15
+ * Bars are scaled to the largest step, since invites can outnumber visits.
16
+ * The rows copy TokenUsageRankedBar: same track, ramp, sizes and columns.
17
+ */
18
+ export function ReferralsAdminFunnel({ summary }: Props) {
19
+ const t = useTranslations();
20
+ const { decimal } = useUsageFormatters();
21
+
22
+ const current = summary.find((row) => row.window === "current");
23
+
24
+ const steps = [
25
+ { label: t("referral.admin.tiles.visits"), value: current?.visits ?? 0 },
26
+ { label: t("referral.admin.tiles.invites"), value: current?.invitesSent ?? 0 },
27
+ { label: t("referral.admin.tiles.signups"), value: current?.signups ?? 0 },
28
+ { label: t("referral.admin.tiles.paying"), value: current?.paying ?? 0 },
29
+ ];
30
+
31
+ const max = Math.max(...steps.map((step) => step.value), 0);
32
+
33
+ return (
34
+ <section data-testid="referrals-funnel" className="grid gap-2">
35
+ <h2 className="text-muted-foreground flex items-center gap-x-2 text-sm font-light whitespace-nowrap">
36
+ {t("referral.admin.funnel.title")}
37
+ </h2>
38
+ <div className="grid gap-0.5">
39
+ {steps.map((step, index) => {
40
+ const width = max > 0 ? (step.value / max) * 100 : 0;
41
+ const before = index > 0 ? steps[index - 1].value : undefined;
42
+ const share =
43
+ before === undefined ? "" : before ? `${decimal(Math.round((step.value / before) * 100), 0)}%` : "—";
44
+ return (
45
+ <div
46
+ key={step.label}
47
+ className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-0.5 py-0.5"
48
+ >
49
+ <div className="grid min-w-0 gap-0.5">
50
+ <span className="text-muted-foreground truncate text-xs">{step.label}</span>
51
+ {/* The bar is a magnitude drawn on an axis, not prose: it always grows
52
+ rightward and only its trailing corner is rounded, so the track and its fill
53
+ stay an LTR island. The label, value and share around it mirror normally.
54
+ rtl-ok: deliberate LTR island (chart) */}
55
+ <div className="bg-muted h-2 w-full overflow-hidden rounded-r-[4px]" dir="ltr">
56
+ <div
57
+ /* rtl-ok: inside the LTR island above */
58
+ className="h-full rounded-r-[4px]"
59
+ style={{
60
+ width: `${width}%`,
61
+ backgroundColor: SEQUENTIAL_RAMP[Math.min(index, SEQUENTIAL_RAMP.length - 1)],
62
+ }}
63
+ />
64
+ </div>
65
+ </div>
66
+ <span className="text-end text-xs tabular-nums">{decimal(step.value, 0)}</span>
67
+ <span className="text-muted-foreground w-16 text-end text-xs tabular-nums">{share}</span>
68
+ </div>
69
+ );
70
+ })}
71
+ </div>
72
+ </section>
73
+ );
74
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { MicroLabel } from "../../../components/typography/MicroLabel";
5
+ import { Card, CardContent } from "../../../shadcnui";
6
+ import { useUsageFormatters } from "../../tokenusage/lib/formatters";
7
+ import { percentageDelta } from "../../tokenusage/lib/metrics";
8
+ import { ReferralAdminSummaryInterface } from "../interfaces/referral-admin.interface";
9
+
10
+ type Props = {
11
+ /** The summary rows: `current`, plus `previous` unless the period is `all`. */
12
+ summary: ReferralAdminSummaryInterface[];
13
+ };
14
+
15
+ type Metric =
16
+ "visits" | "invitesSent" | "signups" | "paying" | "creditsGiven" | "sharingCompanies" | "eligibleCompanies";
17
+
18
+ const ZERO: Record<Metric, number> = {
19
+ visits: 0,
20
+ invitesSent: 0,
21
+ signups: 0,
22
+ paying: 0,
23
+ creditsGiven: 0,
24
+ sharingCompanies: 0,
25
+ eligibleCompanies: 0,
26
+ };
27
+
28
+ /**
29
+ * The KPI row of the administrative referral page. Each tile reads the
30
+ * `current` window; the change line compares it with the `previous` one and is
31
+ * left out when there is no previous window (the `all` period).
32
+ */
33
+ export function ReferralsAdminTiles({ summary }: Props) {
34
+ const t = useTranslations();
35
+ const { decimal } = useUsageFormatters();
36
+
37
+ const current = summary.find((row) => row.window === "current") ?? ZERO;
38
+ const previous = summary.find((row) => row.window === "previous");
39
+
40
+ const change = (metric: Metric): string | undefined => {
41
+ if (!previous) return undefined;
42
+ const delta = percentageDelta(current[metric], previous[metric]);
43
+ const formatted = delta === undefined ? "—" : `${delta > 0 ? "+" : ""}${decimal(delta, 0)}%`;
44
+ return t("referral.admin.tiles.change", { delta: formatted });
45
+ };
46
+
47
+ return (
48
+ <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,4fr)]">
49
+ <LeadTile
50
+ id="sharing"
51
+ label={t("referral.admin.tiles.sharing")}
52
+ value={decimal(current.sharingCompanies, 0)}
53
+ detail={t("referral.admin.tiles.sharingOf", {
54
+ count: decimal(current.sharingCompanies, 0),
55
+ total: decimal(current.eligibleCompanies, 0),
56
+ })}
57
+ />
58
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
59
+ <SupportingTile
60
+ id="visits"
61
+ label={t("referral.admin.tiles.visits")}
62
+ value={decimal(current.visits, 0)}
63
+ change={change("visits")}
64
+ />
65
+ <SupportingTile
66
+ id="invites"
67
+ label={t("referral.admin.tiles.invites")}
68
+ value={decimal(current.invitesSent, 0)}
69
+ change={change("invitesSent")}
70
+ />
71
+ <SupportingTile
72
+ id="signups"
73
+ label={t("referral.admin.tiles.signups")}
74
+ value={decimal(current.signups, 0)}
75
+ change={change("signups")}
76
+ />
77
+ <SupportingTile
78
+ id="paying"
79
+ label={t("referral.admin.tiles.paying")}
80
+ value={decimal(current.paying, 0)}
81
+ change={change("paying")}
82
+ />
83
+ <SupportingTile
84
+ id="credits"
85
+ label={t("referral.admin.tiles.credits")}
86
+ value={decimal(current.creditsGiven, 0)}
87
+ change={change("creditsGiven")}
88
+ />
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** The lead tile: the same card and recipe as AnalyticsAdminTiles' LeadTile. */
95
+ function LeadTile({ id, label, value, detail }: { id: string; label: string; value: string; detail: string }) {
96
+ return (
97
+ <Card data-testid={`referrals-tile-${id}`}>
98
+ <CardContent className="grid gap-1">
99
+ <MicroLabel>{label}</MicroLabel>
100
+ <span data-testid={`referrals-tile-${id}-value`} className="text-primary text-xl font-semibold tabular-nums">
101
+ {value}
102
+ </span>
103
+ <span className="text-muted-foreground text-xs">{detail}</span>
104
+ </CardContent>
105
+ </Card>
106
+ );
107
+ }
108
+
109
+ /** A supporting tile: the same card and recipe as AnalyticsAdminTiles' SupportingTile. */
110
+ function SupportingTile({ id, label, value, change }: { id: string; label: string; value: string; change?: string }) {
111
+ return (
112
+ <Card data-testid={`referrals-tile-${id}`} size="sm">
113
+ <CardContent className="grid gap-1">
114
+ <MicroLabel>{label}</MicroLabel>
115
+ <span className="flex items-center gap-2">
116
+ <span data-testid={`referrals-tile-${id}-value`} className="text-sm font-medium tabular-nums">
117
+ {value}
118
+ </span>
119
+ {change && (
120
+ <span data-testid={`referrals-tile-${id}-change`} className="text-muted-foreground text-xs">
121
+ {change}
122
+ </span>
123
+ )}
124
+ </span>
125
+ </CardContent>
126
+ </Card>
127
+ );
128
+ }
@@ -0,0 +1,205 @@
1
+ "use client";
2
+
3
+ import { CopyIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { ContentListTable, RoundPageContainer } from "../../../../components";
7
+ import { SharedProvider } from "../../../../contexts/SharedContext";
8
+ import { Modules } from "../../../../core";
9
+ import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
10
+ import { BreadcrumbItemData } from "../../../../interfaces";
11
+ import { Button } from "../../../../shadcnui";
12
+ import { ReferralAdminService } from "../../data/ReferralAdminService";
13
+ // Importing the field enums (runtime values) also runs the hook modules, which
14
+ // is what registers the table generators ContentListTable looks up.
15
+ import { ReferralAdminReferralFields } from "../../hooks/useReferralAdminReferralTableStructure";
16
+ import {
17
+ copyReferrerEmails,
18
+ ReferralAdminReferrerFields,
19
+ ReferralAdminReferrerTableContext,
20
+ } from "../../hooks/useReferralAdminReferrerTableStructure";
21
+ import {
22
+ ReferralAdminPeriod,
23
+ ReferralAdminReferralInterface,
24
+ ReferralAdminReferrerInterface,
25
+ ReferralAdminSort,
26
+ ReferralAdminSummaryInterface,
27
+ } from "../../interfaces/referral-admin.interface";
28
+ import { ReferralsAdminFunnel } from "../ReferralsAdminFunnel";
29
+ import { ReferralsAdminTiles } from "../ReferralsAdminTiles";
30
+
31
+ const PERIODS: ReferralAdminPeriod[] = ["30d", "90d", "all"];
32
+
33
+ const REFERRER_FIELDS = [
34
+ ReferralAdminReferrerFields.rank,
35
+ ReferralAdminReferrerFields.referrer,
36
+ ReferralAdminReferrerFields.visits,
37
+ ReferralAdminReferrerFields.invites,
38
+ ReferralAdminReferrerFields.signups,
39
+ ReferralAdminReferrerFields.paying,
40
+ ReferralAdminReferrerFields.conversion,
41
+ ReferralAdminReferrerFields.credits,
42
+ ReferralAdminReferrerFields.lastInvite,
43
+ ];
44
+
45
+ const REFERRAL_FIELDS = [
46
+ ReferralAdminReferralFields.account,
47
+ ReferralAdminReferralFields.referrer,
48
+ ReferralAdminReferralFields.source,
49
+ ReferralAdminReferralFields.date,
50
+ ReferralAdminReferralFields.status,
51
+ ReferralAdminReferralFields.credits,
52
+ ];
53
+
54
+ /**
55
+ * Pushes a changed parameter into a list retriever. The retriever reads its
56
+ * `retrieverParams` only on the first load; a later change goes through
57
+ * `addAdditionalParameter`, which re-fetches and keeps the old rows on screen
58
+ * until the new ones arrive. The first render is skipped: the initial load
59
+ * already carries the value.
60
+ */
61
+ function useRefetchOnChange(retriever: DataListRetriever<unknown>, key: string, value: string) {
62
+ const previous = useRef(value);
63
+ useEffect(() => {
64
+ if (previous.current === value) return;
65
+ previous.current = value;
66
+ retriever.addAdditionalParameter(key, value);
67
+ }, [retriever, key, value]);
68
+ }
69
+
70
+ /**
71
+ * Administrative referral report: period buttons in the title bar, the KPI
72
+ * tiles, the visit-to-paying funnel, the referrers ranking and every referral.
73
+ * Client component by necessity: `module={Modules.ReferralAdminSummary}` is a
74
+ * registry entry carrying an icon component, which a Server Component cannot
75
+ * pass across the boundary.
76
+ */
77
+ export function ReferralsAdminContainer() {
78
+ const t = useTranslations();
79
+ const [period, setPeriod] = useState<ReferralAdminPeriod>("30d");
80
+ const [sort, setSort] = useState<ReferralAdminSort>("paying");
81
+ const [summary, setSummary] = useState<ReferralAdminSummaryInterface[]>([]);
82
+
83
+ useEffect(() => {
84
+ let cancelled = false;
85
+ ReferralAdminService.getSummary({ period })
86
+ .then((rows) => {
87
+ if (!cancelled) setSummary(rows ?? []);
88
+ })
89
+ .catch(() => {
90
+ if (!cancelled) setSummary([]);
91
+ });
92
+ return () => {
93
+ cancelled = true;
94
+ };
95
+ }, [period]);
96
+
97
+ const referrers: DataListRetriever<ReferralAdminReferrerInterface> = useDataListRetriever({
98
+ module: Modules.ReferralAdminReferrer,
99
+ retriever: (params: { period: ReferralAdminPeriod; sort: ReferralAdminSort }) =>
100
+ ReferralAdminService.getReferrers({ period: params.period, sort: params.sort }),
101
+ retrieverParams: { period, sort },
102
+ });
103
+
104
+ const referrals: DataListRetriever<ReferralAdminReferralInterface> = useDataListRetriever({
105
+ module: Modules.ReferralAdminReferral,
106
+ retriever: (params: { period: ReferralAdminPeriod }) =>
107
+ ReferralAdminService.getReferrals({ period: params.period }),
108
+ retrieverParams: { period },
109
+ });
110
+
111
+ useRefetchOnChange(referrers as DataListRetriever<unknown>, "period", period);
112
+ useRefetchOnChange(referrers as DataListRetriever<unknown>, "sort", sort);
113
+ useRefetchOnChange(referrals as DataListRetriever<unknown>, "period", period);
114
+
115
+ const referrerContext = useMemo<ReferralAdminReferrerTableContext>(() => ({ sort, onSort: setSort }), [sort]);
116
+
117
+ const periodLabels: Record<ReferralAdminPeriod, string> = {
118
+ "30d": t("referral.admin.period.30d"),
119
+ "90d": t("referral.admin.period.90d"),
120
+ all: t("referral.admin.period.all"),
121
+ };
122
+
123
+ const periodButtons = (
124
+ <div
125
+ key="referralsAdminPeriod"
126
+ role="group"
127
+ className="border-border inline-flex items-center gap-0.5 rounded-md border p-0.5"
128
+ >
129
+ {PERIODS.map((value) => {
130
+ const selected = value === period;
131
+ return (
132
+ <Button
133
+ key={value}
134
+ type="button"
135
+ size="sm"
136
+ variant={selected ? "default" : "ghost"}
137
+ aria-pressed={selected}
138
+ data-testid={`referrals-period-${value}`}
139
+ onClick={() => setPeriod(value)}
140
+ >
141
+ {periodLabels[value]}
142
+ </Button>
143
+ );
144
+ })}
145
+ </div>
146
+ );
147
+
148
+ const shownEmails = (referrers.data ?? []).map((entry) => entry.email).filter((email) => !!email);
149
+
150
+ const copyAll = (
151
+ <Button
152
+ type="button"
153
+ variant="outline"
154
+ size="sm"
155
+ data-testid="referrals-copy-all-emails"
156
+ disabled={shownEmails.length === 0}
157
+ onClick={() => copyReferrerEmails(shownEmails.join(", "), t)}
158
+ >
159
+ <CopyIcon />
160
+ {t("referral.admin.referrers.copyAll")}
161
+ </Button>
162
+ );
163
+
164
+ const breadcrumbs: BreadcrumbItemData[] = [{ name: t("referral.admin.title") }];
165
+
166
+ return (
167
+ <SharedProvider value={{ breadcrumbs, title: { type: t("referral.admin.title"), functions: periodButtons } }}>
168
+ <RoundPageContainer module={Modules.ReferralAdminSummary} fullWidth forceHeader testId="page-admin-referrals">
169
+ <div className="flex w-full flex-col gap-6 p-4">
170
+ <p className="text-muted-foreground text-xs">{t("referral.admin.description")}</p>
171
+
172
+ <ReferralsAdminTiles summary={summary} />
173
+
174
+ <ReferralsAdminFunnel summary={summary} />
175
+
176
+ <section data-testid="referrals-referrers">
177
+ <ContentListTable
178
+ data={referrers}
179
+ fields={REFERRER_FIELDS}
180
+ tableGeneratorType={Modules.ReferralAdminReferrer}
181
+ context={referrerContext}
182
+ title={t("referral.admin.referrers.title")}
183
+ titleActions={copyAll}
184
+ hidePagination
185
+ allowSearch={false}
186
+ emptyState={t("referral.admin.referrers.empty")}
187
+ />
188
+ </section>
189
+
190
+ <section data-testid="referrals-referrals">
191
+ <ContentListTable
192
+ data={referrals}
193
+ fields={REFERRAL_FIELDS}
194
+ tableGeneratorType={Modules.ReferralAdminReferral}
195
+ title={t("referral.admin.referrals.title")}
196
+ hidePagination
197
+ allowSearch={false}
198
+ emptyState={t("referral.admin.referrals.empty")}
199
+ />
200
+ </section>
201
+ </div>
202
+ </RoundPageContainer>
203
+ </SharedProvider>
204
+ );
205
+ }
@@ -1,3 +1,6 @@
1
1
  export { ReferralCodeCapture } from "./ReferralCodeCapture";
2
2
  export { ReferralDialog, type ReferralDialogProps, type ReferralDialogTranslations } from "./ReferralDialog";
3
3
  export { ReferralWidget, type ReferralWidgetProps, type ReferralWidgetTranslations } from "./ReferralWidget";
4
+ export { ReferralsAdminContainer } from "./containers/ReferralsAdminContainer";
5
+ export { ReferralsAdminTiles } from "./ReferralsAdminTiles";
6
+ export { ReferralsAdminFunnel } from "./ReferralsAdminFunnel";
@@ -0,0 +1,65 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { ReferralAdminReferralInterface } from "../interfaces/referral-admin.interface";
3
+
4
+ /** One referral in the administrative list: the new account and who brought it. Read-only. */
5
+ export class ReferralAdminReferral extends AbstractApiData implements ReferralAdminReferralInterface {
6
+ private _name?: string;
7
+ private _email?: string;
8
+ private _referrerName?: string;
9
+ private _referrerEmail?: string;
10
+ private _source?: string;
11
+ private _status?: string;
12
+ private _referredAt?: Date;
13
+ private _completedAt?: Date;
14
+ private _tokensAwarded?: number;
15
+
16
+ get name(): string {
17
+ return this._name ?? "";
18
+ }
19
+ get email(): string {
20
+ return this._email ?? "";
21
+ }
22
+ get referrerName(): string {
23
+ return this._referrerName ?? "";
24
+ }
25
+ get referrerEmail(): string {
26
+ return this._referrerEmail ?? "";
27
+ }
28
+ get source(): string {
29
+ return this._source ?? "";
30
+ }
31
+ get status(): string {
32
+ return this._status ?? "";
33
+ }
34
+ get referredAt(): Date {
35
+ return this._referredAt ?? new Date(0);
36
+ }
37
+ get completedAt(): Date | undefined {
38
+ return this._completedAt;
39
+ }
40
+ get tokensAwarded(): number | undefined {
41
+ return this._tokensAwarded;
42
+ }
43
+
44
+ rehydrate(data: JsonApiHydratedDataInterface): this {
45
+ super.rehydrate(data);
46
+
47
+ const attrs = data.jsonApi.attributes;
48
+ this._name = attrs.name;
49
+ this._email = attrs.email;
50
+ this._referrerName = attrs.referrerName;
51
+ this._referrerEmail = attrs.referrerEmail;
52
+ this._source = attrs.source;
53
+ this._status = attrs.status;
54
+ this._referredAt = attrs.referredAt ? new Date(attrs.referredAt) : undefined;
55
+ this._completedAt = attrs.completedAt ? new Date(attrs.completedAt) : undefined;
56
+ this._tokensAwarded = attrs.tokensAwarded ?? undefined;
57
+
58
+ return this;
59
+ }
60
+
61
+ /** The resource is read-only: there are no attributes to send. */
62
+ createJsonApi(data: { id: string }) {
63
+ return { data: { type: Modules.ReferralAdminReferral.name, id: data.id, attributes: {} } };
64
+ }
65
+ }
@@ -0,0 +1,65 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { ReferralAdminReferrerInterface } from "../interfaces/referral-admin.interface";
3
+
4
+ /** One referrer company in the referral ranking. Read-only. */
5
+ export class ReferralAdminReferrer extends AbstractApiData implements ReferralAdminReferrerInterface {
6
+ private _name?: string;
7
+ private _email?: string;
8
+ private _visits?: number;
9
+ private _invitesSent?: number;
10
+ private _signupsLink?: number;
11
+ private _signupsDirect?: number;
12
+ private _paying?: number;
13
+ private _creditsEarned?: number;
14
+ private _lastInviteAt?: Date;
15
+
16
+ get name(): string {
17
+ return this._name ?? "";
18
+ }
19
+ get email(): string {
20
+ return this._email ?? "";
21
+ }
22
+ get visits(): number {
23
+ return this._visits ?? 0;
24
+ }
25
+ get invitesSent(): number {
26
+ return this._invitesSent ?? 0;
27
+ }
28
+ get signupsLink(): number {
29
+ return this._signupsLink ?? 0;
30
+ }
31
+ get signupsDirect(): number {
32
+ return this._signupsDirect ?? 0;
33
+ }
34
+ get paying(): number {
35
+ return this._paying ?? 0;
36
+ }
37
+ get creditsEarned(): number {
38
+ return this._creditsEarned ?? 0;
39
+ }
40
+ get lastInviteAt(): Date | undefined {
41
+ return this._lastInviteAt;
42
+ }
43
+
44
+ rehydrate(data: JsonApiHydratedDataInterface): this {
45
+ super.rehydrate(data);
46
+
47
+ const attrs = data.jsonApi.attributes;
48
+ this._name = attrs.name;
49
+ this._email = attrs.email;
50
+ this._visits = attrs.visits;
51
+ this._invitesSent = attrs.invitesSent;
52
+ this._signupsLink = attrs.signupsLink;
53
+ this._signupsDirect = attrs.signupsDirect;
54
+ this._paying = attrs.paying;
55
+ this._creditsEarned = attrs.creditsEarned;
56
+ this._lastInviteAt = attrs.lastInviteAt ? new Date(attrs.lastInviteAt) : undefined;
57
+
58
+ return this;
59
+ }
60
+
61
+ /** The resource is read-only: there are no attributes to send. */
62
+ createJsonApi(data: { id: string }) {
63
+ return { data: { type: Modules.ReferralAdminReferrer.name, id: data.id, attributes: {} } };
64
+ }
65
+ }
@@ -0,0 +1,51 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import {
3
+ ReferralAdminPeriod,
4
+ ReferralAdminReferralInterface,
5
+ ReferralAdminReferrerInterface,
6
+ ReferralAdminSort,
7
+ ReferralAdminSummaryInterface,
8
+ } from "../interfaces/referral-admin.interface";
9
+
10
+ /** Reads of the administrative referral report. Every route is administrator-only. */
11
+ export class ReferralAdminService extends AbstractService {
12
+ /** Headline tiles: one row per window (`current`, plus `previous` unless the period is `all`). */
13
+ static async getSummary(params: { period: ReferralAdminPeriod }): Promise<ReferralAdminSummaryInterface[]> {
14
+ const endpoint = new EndpointCreator({ endpoint: Modules.ReferralAdminSummary });
15
+ endpoint.addAdditionalParam("period", params.period);
16
+
17
+ return this.callApi<ReferralAdminSummaryInterface[]>({
18
+ type: Modules.ReferralAdminSummary,
19
+ method: HttpMethod.GET,
20
+ endpoint: endpoint.generate(),
21
+ });
22
+ }
23
+
24
+ /** The referrers ranking, ordered by the allowlisted sort. */
25
+ static async getReferrers(params: {
26
+ period: ReferralAdminPeriod;
27
+ sort: ReferralAdminSort;
28
+ }): Promise<ReferralAdminReferrerInterface[]> {
29
+ const endpoint = new EndpointCreator({ endpoint: Modules.ReferralAdminReferrer });
30
+ endpoint.addAdditionalParam("period", params.period);
31
+ endpoint.addAdditionalParam("sort", params.sort);
32
+
33
+ return this.callApi<ReferralAdminReferrerInterface[]>({
34
+ type: Modules.ReferralAdminReferrer,
35
+ method: HttpMethod.GET,
36
+ endpoint: endpoint.generate(),
37
+ });
38
+ }
39
+
40
+ /** Every referral in the period, newest first. */
41
+ static async getReferrals(params: { period: ReferralAdminPeriod }): Promise<ReferralAdminReferralInterface[]> {
42
+ const endpoint = new EndpointCreator({ endpoint: Modules.ReferralAdminReferral });
43
+ endpoint.addAdditionalParam("period", params.period);
44
+
45
+ return this.callApi<ReferralAdminReferralInterface[]>({
46
+ type: Modules.ReferralAdminReferral,
47
+ method: HttpMethod.GET,
48
+ endpoint: endpoint.generate(),
49
+ });
50
+ }
51
+ }
@@ -0,0 +1,60 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { ReferralAdminSummaryInterface } from "../interfaces/referral-admin.interface";
3
+
4
+ /** One window of the referral report tiles: `current` or `previous`. Read-only. */
5
+ export class ReferralAdminSummary extends AbstractApiData implements ReferralAdminSummaryInterface {
6
+ private _window?: string;
7
+ private _visits?: number;
8
+ private _invitesSent?: number;
9
+ private _signups?: number;
10
+ private _paying?: number;
11
+ private _creditsGiven?: number;
12
+ private _sharingCompanies?: number;
13
+ private _eligibleCompanies?: number;
14
+
15
+ get window(): string {
16
+ return this._window ?? "";
17
+ }
18
+ get visits(): number {
19
+ return this._visits ?? 0;
20
+ }
21
+ get invitesSent(): number {
22
+ return this._invitesSent ?? 0;
23
+ }
24
+ get signups(): number {
25
+ return this._signups ?? 0;
26
+ }
27
+ get paying(): number {
28
+ return this._paying ?? 0;
29
+ }
30
+ get creditsGiven(): number {
31
+ return this._creditsGiven ?? 0;
32
+ }
33
+ get sharingCompanies(): number {
34
+ return this._sharingCompanies ?? 0;
35
+ }
36
+ get eligibleCompanies(): number {
37
+ return this._eligibleCompanies ?? 0;
38
+ }
39
+
40
+ rehydrate(data: JsonApiHydratedDataInterface): this {
41
+ super.rehydrate(data);
42
+
43
+ const attrs = data.jsonApi.attributes;
44
+ this._window = attrs.window;
45
+ this._visits = attrs.visits;
46
+ this._invitesSent = attrs.invitesSent;
47
+ this._signups = attrs.signups;
48
+ this._paying = attrs.paying;
49
+ this._creditsGiven = attrs.creditsGiven;
50
+ this._sharingCompanies = attrs.sharingCompanies;
51
+ this._eligibleCompanies = attrs.eligibleCompanies;
52
+
53
+ return this;
54
+ }
55
+
56
+ /** The resource is read-only: there are no attributes to send. */
57
+ createJsonApi(data: { id: string }) {
58
+ return { data: { type: Modules.ReferralAdminSummary.name, id: data.id, attributes: {} } };
59
+ }
60
+ }
@@ -33,6 +33,26 @@ export class ReferralService extends AbstractService {
33
33
  endpoint: endpoint.generate(),
34
34
  input: { email },
35
35
  overridesJsonApiCreation: true,
36
+ // The widget turns the error code into a translated message; the global
37
+ // handler would show the API's English text and swallow the failure.
38
+ suppressGlobalError: true,
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Count one visit to a referral link. Answers 204 whatever the code.
44
+ */
45
+ static async recordVisit(params: { referralCode: string }): Promise<void> {
46
+ const endpoint = new EndpointCreator({ endpoint: Modules.ReferralVisit });
47
+
48
+ await this.callApi<void>({
49
+ type: Modules.ReferralVisit,
50
+ method: HttpMethod.POST,
51
+ endpoint: endpoint.generate(),
52
+ // The request layer builds the body through ReferralVisit.createJsonApi().
53
+ input: { referralCode: params.referralCode },
54
+ // A failed beacon must never show an error to a visitor.
55
+ suppressGlobalError: true,
36
56
  });
37
57
  }
38
58
  }