Add processors filter to customer list dropdown for filtering by Stripe, RevenueCat, or Vercel
This commit is contained in:
@@ -53,6 +53,7 @@ interface SearchFilters {
|
||||
status?: string[];
|
||||
version?: string[];
|
||||
none?: boolean;
|
||||
processor?: string[];
|
||||
}
|
||||
|
||||
export class CusSearchService {
|
||||
@@ -230,6 +231,22 @@ export class CusSearchService {
|
||||
ilike(customers.email, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
|
||||
filters.processor?.length
|
||||
? or(
|
||||
...filters.processor
|
||||
.map((proc) => {
|
||||
if (proc === "stripe")
|
||||
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
|
||||
if (proc === "revenuecat")
|
||||
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
|
||||
if (proc === "vercel")
|
||||
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
|
||||
return undefined;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Build the where clause
|
||||
@@ -409,6 +426,21 @@ export class CusSearchService {
|
||||
)
|
||||
: undefined,
|
||||
noneFilter,
|
||||
filters?.processor?.length
|
||||
? or(
|
||||
...filters.processor
|
||||
.map((proc) => {
|
||||
if (proc === "stripe")
|
||||
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
|
||||
if (proc === "revenuecat")
|
||||
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
|
||||
if (proc === "vercel")
|
||||
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
|
||||
return undefined;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
let baseQuery;
|
||||
@@ -451,6 +483,23 @@ export class CusSearchService {
|
||||
)
|
||||
: undefined,
|
||||
noneFilter,
|
||||
filters?.processor?.length
|
||||
? or(
|
||||
...filters.processor
|
||||
.map((proc) => {
|
||||
if (proc === "stripe")
|
||||
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
|
||||
if (proc === "revenuecat")
|
||||
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
|
||||
if (proc === "vercel")
|
||||
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
|
||||
return undefined;
|
||||
})
|
||||
.filter(
|
||||
(c): c is NonNullable<typeof c> => c !== undefined,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
]);
|
||||
@@ -553,6 +602,21 @@ export class CusSearchService {
|
||||
ilike(customers.email, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
filters?.processor?.length
|
||||
? or(
|
||||
...filters.processor
|
||||
.map((proc) => {
|
||||
if (proc === "stripe")
|
||||
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
|
||||
if (proc === "revenuecat")
|
||||
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
|
||||
if (proc === "vercel")
|
||||
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
|
||||
return undefined;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Build the where clause for base query
|
||||
|
||||
@@ -302,10 +302,10 @@ export class CusService {
|
||||
ctx: AutumnContext;
|
||||
query?: Pick<
|
||||
ListCustomersV2Params,
|
||||
"plans" | "search" | "subscription_status"
|
||||
"plans" | "search" | "subscription_status" | "processors"
|
||||
>;
|
||||
}): Promise<{ total_filtered_count: number }> {
|
||||
if (!hasCustomerListFilters(query ?? {})) {
|
||||
if (!hasCustomerListFilters({ ...query })) {
|
||||
const { total_count } = await CusService.countByOrgIdAndEnv({ ctx });
|
||||
return { total_filtered_count: total_count };
|
||||
}
|
||||
@@ -315,6 +315,23 @@ export class CusService {
|
||||
? [query.subscription_status as CusProductStatus]
|
||||
: ACTIVE_STATUSES;
|
||||
|
||||
const processorFilter =
|
||||
query?.processors?.length
|
||||
? or(
|
||||
...query.processors
|
||||
.map((proc) => {
|
||||
if (proc === "stripe")
|
||||
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
|
||||
if (proc === "revenuecat")
|
||||
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
|
||||
if (proc === "vercel")
|
||||
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
|
||||
return undefined;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!query?.plans?.length) {
|
||||
const [result] = await ctx.db
|
||||
.select({ total_filtered_count: count() })
|
||||
@@ -330,6 +347,7 @@ export class CusService {
|
||||
ilike(customers.email, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
processorFilter,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -382,17 +400,20 @@ export class CusService {
|
||||
.where(
|
||||
and(
|
||||
inArray(customerProducts.internal_product_id, internalProductIds),
|
||||
search
|
||||
? and(
|
||||
eq(customers.org_id, ctx.org.id),
|
||||
eq(customers.env, ctx.env),
|
||||
or(
|
||||
ilike(customers.id, `%${search}%`),
|
||||
ilike(customers.name, `%${search}%`),
|
||||
ilike(customers.email, `%${search}%`),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
or(
|
||||
search
|
||||
? and(
|
||||
eq(customers.org_id, ctx.org.id),
|
||||
eq(customers.env, ctx.env),
|
||||
or(
|
||||
ilike(customers.id, `%${search}%`),
|
||||
ilike(customers.name, `%${search}%`),
|
||||
ilike(customers.email, `%${search}%`),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
processorFilter,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -712,16 +712,19 @@ export const hasCustomerListFilters = ({
|
||||
internalCustomerIds,
|
||||
inStatuses: _inStatuses,
|
||||
plans,
|
||||
processors,
|
||||
search,
|
||||
}: {
|
||||
internalCustomerIds?: string[];
|
||||
inStatuses?: CusProductStatus[];
|
||||
plans?: ListCustomersV2Params["plans"];
|
||||
processors?: ListCustomersV2Params["processors"];
|
||||
search?: string;
|
||||
}) => {
|
||||
return Boolean(
|
||||
(internalCustomerIds && internalCustomerIds.length > 0) ||
|
||||
(plans && plans.length > 0) ||
|
||||
(processors && processors.length > 0) ||
|
||||
search?.trim(),
|
||||
);
|
||||
};
|
||||
@@ -730,11 +733,13 @@ export const getCustomerListFilterSql = ({
|
||||
internalCustomerIds,
|
||||
inStatuses,
|
||||
plans,
|
||||
processors,
|
||||
search,
|
||||
}: {
|
||||
internalCustomerIds?: string[];
|
||||
inStatuses?: CusProductStatus[];
|
||||
plans?: ListCustomersV2Params["plans"];
|
||||
processors?: ListCustomersV2Params["processors"];
|
||||
search?: string;
|
||||
}) => {
|
||||
const filters = [];
|
||||
@@ -787,5 +792,25 @@ export const getCustomerListFilterSql = ({
|
||||
)`);
|
||||
}
|
||||
|
||||
if (processors && processors.length > 0) {
|
||||
const processorConditions = processors
|
||||
.map((proc) => {
|
||||
if (proc === "stripe")
|
||||
return sql`(c.processor->>'id' IS NOT NULL)`;
|
||||
if (proc === "revenuecat")
|
||||
return sql`(c.processors->>'revenuecat' IS NOT NULL)`;
|
||||
if (proc === "vercel")
|
||||
return sql`(c.processors->>'vercel' IS NOT NULL)`;
|
||||
return null;
|
||||
})
|
||||
.filter((c): c is SQL => c !== null);
|
||||
|
||||
if (processorConditions.length > 0) {
|
||||
filters.push(
|
||||
sql`AND (${sql.join(processorConditions, sql` OR `)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sql.join(filters, sql` `);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const handleListCustomersV2 = createRoute({
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
const hasFilteredQuery = Boolean(body.plans?.length || body.search?.trim() || body.subscription_status);
|
||||
const hasFilteredQuery = Boolean(body.plans?.length || body.search?.trim() || body.subscription_status || body.processors?.length);
|
||||
|
||||
const [customers, totalCount] = await Promise.all([
|
||||
CusBatchService.getPage({ ctx, query: body }),
|
||||
@@ -32,6 +32,7 @@ export const handleListCustomersV2 = createRoute({
|
||||
plans: body.plans,
|
||||
search: body.search,
|
||||
subscription_status: body.subscription_status,
|
||||
processors: body.processors,
|
||||
},
|
||||
})
|
||||
: { total_filtered_count: totalCount.total_count };
|
||||
|
||||
@@ -25,6 +25,14 @@ export const ListCustomersV2ParamsSchema = createPaginationParamsSchema({
|
||||
search: z.string().optional().meta({
|
||||
description: "Search customers by id, name, or email",
|
||||
}),
|
||||
|
||||
processors: z
|
||||
.array(z.enum(["stripe", "revenuecat", "vercel"]))
|
||||
.optional()
|
||||
.meta({
|
||||
description:
|
||||
"Filter by customer processor type (stripe, revenuecat, vercel)",
|
||||
}),
|
||||
});
|
||||
|
||||
export type ListCustomersV2Params = z.infer<typeof ListCustomersV2ParamsSchema>;
|
||||
|
||||
@@ -84,6 +84,12 @@ export const customers = pgTable(
|
||||
table.env,
|
||||
sql`${table.created_at} DESC`,
|
||||
),
|
||||
index("idx_customers_processors_revenuecat").on(
|
||||
sql`(${table.processors} ->> 'revenuecat')`,
|
||||
),
|
||||
index("idx_customers_processors_vercel").on(
|
||||
sql`(${table.processors} ->> 'vercel')`,
|
||||
),
|
||||
],
|
||||
).enableRLS();
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { CoinVerticalIcon, TriangleIcon } from "@phosphor-icons/react";
|
||||
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons";
|
||||
import { useCustomerFilters } from "../../hooks/useCustomerFilters";
|
||||
|
||||
type Processor = "stripe" | "revenuecat" | "vercel";
|
||||
|
||||
const PROCESSORS: { value: Processor; label: string; icon: React.ReactNode }[] =
|
||||
[
|
||||
{
|
||||
value: "stripe",
|
||||
label: "Stripe",
|
||||
icon: <CoinVerticalIcon size={14} weight="fill" />,
|
||||
},
|
||||
{
|
||||
value: "revenuecat",
|
||||
label: "RevenueCat",
|
||||
icon: <RevenueCatIcon size={14} />,
|
||||
},
|
||||
{
|
||||
value: "vercel",
|
||||
label: "Vercel",
|
||||
icon: <TriangleIcon size={14} weight="fill" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const ProcessorSubMenu = () => {
|
||||
const { queryStates, setFilters } = useCustomerFilters();
|
||||
|
||||
const selectedProcessors = queryStates.processor || [];
|
||||
const hasSelections = selectedProcessors.length > 0;
|
||||
|
||||
const toggleProcessor = (processor: Processor) => {
|
||||
const isSelected = selectedProcessors.includes(processor);
|
||||
const updated = isSelected
|
||||
? selectedProcessors.filter((p: string) => p !== processor)
|
||||
: [...selectedProcessors, processor];
|
||||
|
||||
setFilters({ processor: updated });
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer">
|
||||
Processors
|
||||
{hasSelections && (
|
||||
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
|
||||
{selectedProcessors.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{PROCESSORS.map(({ value, label, icon }) => {
|
||||
const isActive = selectedProcessors.includes(value);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleProcessor(value);
|
||||
}}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center gap-2 cursor-pointer text-sm"
|
||||
>
|
||||
<Checkbox checked={isActive} className="border-border" />
|
||||
{icon}
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
@@ -47,12 +47,14 @@ export const SavedViews = ({
|
||||
const statusParam = params.get("status") || "";
|
||||
const versionParam = params.get("version") || "";
|
||||
const noneParam = params.get("none");
|
||||
const processorParam = params.get("processor") || "";
|
||||
|
||||
setFilters({
|
||||
q: params.get("q") || "",
|
||||
status: statusParam ? statusParam.split(",").filter(Boolean) : [],
|
||||
version: versionParam ? versionParam.split(",").filter(Boolean) : [],
|
||||
none: noneParam === "true",
|
||||
processor: processorParam ? processorParam.split(",").filter(Boolean) : [],
|
||||
});
|
||||
|
||||
toast.success(`Applied filters from ${view.name} view`);
|
||||
|
||||
@@ -18,6 +18,7 @@ export const useCusSearchQuery = () => {
|
||||
status: queryStates.status,
|
||||
version: queryStates.version,
|
||||
none: queryStates.none,
|
||||
processor: queryStates.processor,
|
||||
},
|
||||
page: queryStates.page,
|
||||
page_size: queryStates.pageSize,
|
||||
@@ -45,6 +46,7 @@ export const useCusSearchQuery = () => {
|
||||
queryStates.status,
|
||||
queryStates.version,
|
||||
queryStates.none,
|
||||
queryStates.processor,
|
||||
trimmedSearch,
|
||||
]),
|
||||
queryFn: fetcher,
|
||||
|
||||
@@ -23,6 +23,7 @@ const FILTER_PARAM_KEYS = [
|
||||
"status",
|
||||
"version",
|
||||
"none",
|
||||
"processor",
|
||||
"page",
|
||||
"pageSize",
|
||||
] as const;
|
||||
@@ -31,6 +32,7 @@ type PersistedCustomerFilters = {
|
||||
status: string[];
|
||||
version: string[];
|
||||
none: boolean;
|
||||
processor: string[];
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
@@ -63,6 +65,7 @@ function buildRestoredState({
|
||||
status: filters?.status?.length ? filters.status : null,
|
||||
version: filters?.version?.length ? filters.version : null,
|
||||
none: filters?.none ? true : null,
|
||||
processor: filters?.processor?.length ? filters.processor : null,
|
||||
page: null,
|
||||
pageSize:
|
||||
filters?.pageSize && filters.pageSize !== 50 ? filters.pageSize : null,
|
||||
@@ -75,6 +78,7 @@ const queryStatesConfig = {
|
||||
status: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
version: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
none: parseAsBoolean.withDefault(false),
|
||||
processor: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
page: parseAsInteger.withDefault(1),
|
||||
pageSize: parseAsInteger.withDefault(50),
|
||||
lastItemId: parseAsString.withDefault(""),
|
||||
@@ -155,6 +159,7 @@ export function CustomerFiltersProvider({
|
||||
status: queryStates.status,
|
||||
version: queryStates.version,
|
||||
none: queryStates.none,
|
||||
processor: queryStates.processor,
|
||||
pageSize: queryStates.pageSize,
|
||||
}),
|
||||
);
|
||||
@@ -166,6 +171,7 @@ export function CustomerFiltersProvider({
|
||||
queryStates.status,
|
||||
queryStates.version,
|
||||
queryStates.none,
|
||||
queryStates.processor,
|
||||
queryStates.pageSize,
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const useFullCusSearchQuery = () => {
|
||||
queryStates.status,
|
||||
queryStates.version,
|
||||
queryStates.none,
|
||||
queryStates.processor,
|
||||
queryStates.q,
|
||||
]),
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -34,6 +35,7 @@ export const useFullCusSearchQuery = () => {
|
||||
status: queryStates.status,
|
||||
version: queryStates.version,
|
||||
none: queryStates.none,
|
||||
processor: queryStates.processor,
|
||||
},
|
||||
},
|
||||
{ signal },
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FilterStatusSubMenu } from "@/views/customers/components/filter-dropdown/FilterStatusSubMenu";
|
||||
import { ProcessorSubMenu } from "@/views/customers/components/filter-dropdown/ProcessorSubMenu";
|
||||
import { ProductsSubMenu } from "@/views/customers/components/filter-dropdown/ProductsSubMenu";
|
||||
import { SaveViewPopover } from "@/views/customers/components/filter-dropdown/SavedViewPopover";
|
||||
import { SavedViews } from "@/views/customers/components/filter-dropdown/SavedViews";
|
||||
@@ -25,14 +26,15 @@ export function CustomerListFilterButton() {
|
||||
const hasActiveFilters =
|
||||
queryStates.status.length > 0 ||
|
||||
queryStates.version.length > 0 ||
|
||||
queryStates.none;
|
||||
queryStates.none ||
|
||||
queryStates.processor.length > 0;
|
||||
|
||||
const { data, refetch: refetchSavedViews } = useSavedViewsQuery();
|
||||
|
||||
const views = data?.views || [];
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({ status: [], version: [], none: false });
|
||||
setFilters({ status: [], version: [], none: false, processor: [] });
|
||||
};
|
||||
|
||||
const closeFilterModal = () => {
|
||||
@@ -62,6 +64,7 @@ export function CustomerListFilterButton() {
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<FilterStatusSubMenu />
|
||||
<ProductsSubMenu />
|
||||
<ProcessorSubMenu />
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
{views.length > 0 && (
|
||||
|
||||
@@ -56,6 +56,7 @@ export function CustomerListTable({
|
||||
queryStates.status,
|
||||
queryStates.version,
|
||||
queryStates.none,
|
||||
queryStates.processor,
|
||||
queryStates.q,
|
||||
]),
|
||||
// Placeholder queryFn - actual fetching is done by useFullCusSearchQuery
|
||||
@@ -140,7 +141,8 @@ export function CustomerListTable({
|
||||
const hasFilters =
|
||||
queryStates.status.length > 0 ||
|
||||
queryStates.version.length > 0 ||
|
||||
queryStates.none;
|
||||
queryStates.none ||
|
||||
queryStates.processor.length > 0;
|
||||
const hasActiveFiltersOrSearch = hasSearchQuery || hasFilters;
|
||||
|
||||
// Only show empty state if org has NO customers (no filters/search active and no results)
|
||||
|
||||
Reference in New Issue
Block a user