Skip to content

Commit ac4912f

Browse files
Partners number stat filter (dubinc#3625)
Co-authored-by: Steven Tey <stevensteel97@gmail.com>
1 parent c575a59 commit ac4912f

11 files changed

Lines changed: 1410 additions & 264 deletions

File tree

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx

Lines changed: 250 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,96 @@ import useWorkspace from "@/lib/swr/use-workspace";
44
import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle";
55
import { PartnerStatusBadges } from "@/ui/partners/partner-status-badges";
66
import { ProgramEnrollmentStatus } from "@dub/prisma/client";
7-
import { useRouterStuff } from "@dub/ui";
8-
import { CircleDotted, FlagWavy, Users6 } from "@dub/ui/icons";
9-
import { cn, COUNTRIES, nFormatter } from "@dub/utils";
7+
import { encodeRangeToken, parseRangeToken, useRouterStuff } from "@dub/ui";
8+
import {
9+
CircleDotted,
10+
CursorRays,
11+
FlagWavy,
12+
InvoiceDollar,
13+
MarketingTarget,
14+
MoneyBills2,
15+
UserPlus,
16+
Users6,
17+
} from "@dub/ui/icons";
18+
import { cn, COUNTRIES, currencyFormatter, nFormatter } from "@dub/utils";
1019
import { useMemo } from "react";
1120

21+
const PARTNER_METRIC_RANGE = [
22+
{
23+
filterKey: "totalClicks",
24+
minParam: "totalClicksMin",
25+
maxParam: "totalClicksMax",
26+
metric: "totalClicks" as const,
27+
label: "Clicks",
28+
icon: CursorRays,
29+
},
30+
{
31+
filterKey: "totalLeads",
32+
minParam: "totalLeadsMin",
33+
maxParam: "totalLeadsMax",
34+
metric: "totalLeads" as const,
35+
label: "Leads",
36+
icon: UserPlus,
37+
},
38+
{
39+
filterKey: "totalConversions",
40+
minParam: "totalConversionsMin",
41+
maxParam: "totalConversionsMax",
42+
metric: "totalConversions" as const,
43+
label: "Conversions",
44+
icon: MarketingTarget,
45+
},
46+
{
47+
filterKey: "totalSaleAmount",
48+
minParam: "totalSaleAmountMin",
49+
maxParam: "totalSaleAmountMax",
50+
metric: "totalSaleAmount" as const,
51+
label: "Revenue",
52+
icon: InvoiceDollar,
53+
formatRangeBound: (n: number) => currencyFormatter(n),
54+
parseRangeInput: (raw: string) => {
55+
const n = Number.parseFloat(raw.replace(/[^0-9.-]/g, ""));
56+
if (!Number.isFinite(n)) {
57+
return Number.NaN;
58+
}
59+
return Math.round(n * 100);
60+
},
61+
},
62+
{
63+
filterKey: "totalCommissions",
64+
minParam: "totalCommissionsMin",
65+
maxParam: "totalCommissionsMax",
66+
metric: "totalCommissions" as const,
67+
label: "Commissions",
68+
icon: MoneyBills2,
69+
formatRangeBound: (n: number) => currencyFormatter(n),
70+
parseRangeInput: (raw: string) => {
71+
const n = Number.parseFloat(raw.replace(/[^0-9.-]/g, ""));
72+
if (!Number.isFinite(n)) {
73+
return Number.NaN;
74+
}
75+
return Math.round(n * 100);
76+
},
77+
},
78+
] as const;
79+
80+
export type PartnerFilterKey =
81+
| "groupId"
82+
| "status"
83+
| "country"
84+
| (typeof PARTNER_METRIC_RANGE)[number]["filterKey"];
85+
1286
export function usePartnerFilters(
1387
extraSearchParams: Record<string, string>,
14-
enabledFilters: ("groupId" | "status" | "country")[] = [
88+
enabledFilters: PartnerFilterKey[] = [
1589
"groupId",
1690
"status",
1791
"country",
92+
"totalClicks",
93+
"totalLeads",
94+
"totalConversions",
95+
"totalSaleAmount",
96+
"totalCommissions",
1897
],
1998
) {
2099
const { searchParamsObj, queryParams } = useRouterStuff();
@@ -25,6 +104,15 @@ export function usePartnerFilters(
25104

26105
const { groups } = useGroups();
27106

107+
const cohortParams = useMemo(
108+
() => ({
109+
...(searchParamsObj.groupId && { groupId: searchParamsObj.groupId }),
110+
...(searchParamsObj.country && { country: searchParamsObj.country }),
111+
...(searchParamsObj.search && { search: searchParamsObj.search }),
112+
}),
113+
[searchParamsObj.groupId, searchParamsObj.country, searchParamsObj.search],
114+
);
115+
28116
const { partnersCount: countriesCount } = usePartnersCount<
29117
| {
30118
country: string;
@@ -34,6 +122,7 @@ export function usePartnerFilters(
34122
>({
35123
groupBy: "country",
36124
status,
125+
...cohortParams,
37126
enabled: enabledFilters.includes("country"),
38127
});
39128

@@ -44,7 +133,9 @@ export function usePartnerFilters(
44133
}[]
45134
| undefined
46135
>({
47-
groupBy: "status", // here we include all statuses to get the groupBy count
136+
groupBy: "status",
137+
status,
138+
...cohortParams,
48139
enabled: enabledFilters.includes("status"),
49140
});
50141

@@ -57,6 +148,7 @@ export function usePartnerFilters(
57148
>({
58149
groupBy: "groupId",
59150
status,
151+
...cohortParams,
60152
enabled: enabledFilters.includes("groupId"),
61153
});
62154

@@ -128,14 +220,17 @@ export function usePartnerFilters(
128220
key: "country",
129221
icon: FlagWavy,
130222
label: "Location",
131-
getOptionIcon: (value) => (
223+
separatorAfter: PARTNER_METRIC_RANGE.some((m) =>
224+
enabledFilters.includes(m.filterKey),
225+
),
226+
getOptionIcon: (value: string) => (
132227
<img
133228
alt={value}
134229
src={`https://hatscripts.github.io/circle-flags/flags/${value.toLowerCase()}.svg`}
135230
className="size-4 shrink-0"
136231
/>
137232
),
138-
getOptionLabel: (value) => COUNTRIES[value],
233+
getOptionLabel: (value: string) => COUNTRIES[value],
139234
options:
140235
countriesCount
141236
?.filter(({ country }) => COUNTRIES[country])
@@ -147,62 +242,187 @@ export function usePartnerFilters(
147242
},
148243
]
149244
: []),
245+
...PARTNER_METRIC_RANGE.filter((m) =>
246+
enabledFilters.includes(m.filterKey),
247+
).map((m) => {
248+
const formatRangeBound =
249+
"formatRangeBound" in m && m.formatRangeBound
250+
? m.formatRangeBound
251+
: (n: number) => nFormatter(n, { full: true });
252+
const parseRangeInput =
253+
"parseRangeInput" in m && m.parseRangeInput
254+
? m.parseRangeInput
255+
: (raw: string) => {
256+
const n = Number.parseInt(raw.replace(/[^\d-]/g, ""), 10);
257+
return Number.isFinite(n) ? n : Number.NaN;
258+
};
259+
return {
260+
key: m.filterKey,
261+
icon: m.icon,
262+
label: m.label,
263+
type: "range" as const,
264+
options: null,
265+
...(m.metric === "totalCommissions"
266+
? {
267+
rangeDisplayScale: 100,
268+
rangeNumberStep: 0.01,
269+
}
270+
: {}),
271+
formatRangeBound,
272+
parseRangeInput,
273+
formatRangePillLabel: (token: string) => {
274+
const { min, max } = parseRangeToken(token);
275+
if (min != null && max != null) {
276+
return `${formatRangeBound(min)}${formatRangeBound(max)}`;
277+
}
278+
if (min != null) {
279+
return `${formatRangeBound(min)} – No max`;
280+
}
281+
if (max != null) {
282+
return `No min – ${formatRangeBound(max)}`;
283+
}
284+
return token;
285+
},
286+
};
287+
}),
150288
],
151-
[groupsCount, groups, statusCount, countriesCount],
289+
[groupsCount, groups, statusCount, countriesCount, slug, enabledFilters],
152290
);
153291

154292
const activeFilters = useMemo(() => {
155-
const { groupId, status, country } = searchParamsObj;
293+
const { groupId, status: statusParam, country } = searchParamsObj;
156294

157295
return [
158296
...(enabledFilters.includes("groupId") && groupId
159297
? [{ key: "groupId", value: groupId }]
160298
: []),
161-
...(enabledFilters.includes("status") && status
162-
? [{ key: "status", value: status }]
299+
...(enabledFilters.includes("status") && statusParam
300+
? [{ key: "status", value: statusParam }]
163301
: []),
164302
...(enabledFilters.includes("country") && country
165303
? [{ key: "country", value: country }]
166304
: []),
305+
...PARTNER_METRIC_RANGE.filter((m) =>
306+
enabledFilters.includes(m.filterKey),
307+
).flatMap((m) => {
308+
const minRaw = searchParamsObj[m.minParam];
309+
const maxRaw = searchParamsObj[m.maxParam];
310+
const min =
311+
minRaw !== undefined && minRaw !== "" ? Number(minRaw) : undefined;
312+
const max =
313+
maxRaw !== undefined && maxRaw !== "" ? Number(maxRaw) : undefined;
314+
const minOk = min !== undefined && Number.isFinite(min);
315+
const maxOk = max !== undefined && Number.isFinite(max);
316+
if (!minOk && !maxOk) {
317+
return [];
318+
}
319+
return [
320+
{
321+
key: m.filterKey,
322+
value: encodeRangeToken(
323+
minOk ? min : undefined,
324+
maxOk ? max : undefined,
325+
),
326+
},
327+
];
328+
}),
167329
];
168-
}, [searchParamsObj]);
330+
}, [searchParamsObj, enabledFilters]);
331+
332+
const onSelect = (key: string, value: unknown) => {
333+
const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key);
334+
if (metric) {
335+
const { min, max } = parseRangeToken(String(value));
336+
queryParams({
337+
set: {
338+
...(min != null ? { [metric.minParam]: String(min) } : {}),
339+
...(max != null ? { [metric.maxParam]: String(max) } : {}),
340+
},
341+
del: [
342+
...(min == null ? [metric.minParam] : []),
343+
...(max == null ? [metric.maxParam] : []),
344+
"page",
345+
],
346+
});
347+
return;
348+
}
169349

170-
const onSelect = (key: string, value: any) =>
171350
queryParams({
172-
set: {
173-
[key]: value,
174-
},
351+
set: { [key]: value as string },
175352
del: "page",
176353
});
354+
};
355+
356+
const onRemove = (key: string, _value?: unknown) => {
357+
const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key);
358+
if (metric) {
359+
queryParams({
360+
del: [metric.minParam, metric.maxParam, "page"],
361+
});
362+
return;
363+
}
177364

178-
const onRemove = (key: string) =>
179365
queryParams({
180366
del: [key, "page"],
181367
});
368+
};
369+
370+
const onRemoveFilter = (key: string) => {
371+
onRemove(key);
372+
};
182373

183374
const onRemoveAll = () =>
184375
queryParams({
185-
del: ["status", "country", "groupId", "search"],
376+
del: [
377+
"status",
378+
"country",
379+
"groupId",
380+
"search",
381+
"totalClicksMin",
382+
"totalClicksMax",
383+
"totalLeadsMin",
384+
"totalLeadsMax",
385+
"totalConversionsMin",
386+
"totalConversionsMax",
387+
"totalSaleAmountMin",
388+
"totalSaleAmountMax",
389+
"totalCommissionsMin",
390+
"totalCommissionsMax",
391+
"page",
392+
],
186393
});
187394

188-
const searchQuery = useMemo(
189-
() =>
190-
new URLSearchParams({
191-
...Object.fromEntries(
192-
activeFilters.map(({ key, value }) => [key, value]),
193-
),
194-
...(searchParamsObj.search && { search: searchParamsObj.search }),
195-
workspaceId: workspaceId || "",
196-
...extraSearchParams,
197-
}).toString(),
198-
[activeFilters, workspaceId, extraSearchParams],
199-
);
395+
const searchQuery = useMemo(() => {
396+
const acc: Record<string, string> = {
397+
workspaceId: workspaceId || "",
398+
...extraSearchParams,
399+
};
400+
if (searchParamsObj.search) {
401+
acc.search = searchParamsObj.search;
402+
}
403+
for (const { key, value } of activeFilters) {
404+
const metric = PARTNER_METRIC_RANGE.find((m) => m.filterKey === key);
405+
if (metric) {
406+
const { min, max } = parseRangeToken(String(value));
407+
if (min != null) {
408+
acc[metric.minParam] = String(min);
409+
}
410+
if (max != null) {
411+
acc[metric.maxParam] = String(max);
412+
}
413+
} else {
414+
acc[key] = String(value);
415+
}
416+
}
417+
return new URLSearchParams(acc).toString();
418+
}, [activeFilters, workspaceId, extraSearchParams, searchParamsObj.search]);
200419

201420
return {
202421
filters,
203422
activeFilters,
204423
onSelect,
205424
onRemove,
425+
onRemoveFilter,
206426
onRemoveAll,
207427
searchQuery,
208428
};

0 commit comments

Comments
 (0)