Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions apps/web/app/(ee)/api/customers/count/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withWorkspace } from "@/lib/auth";
import { getCustomersCountQuerySchema } from "@/lib/zod/schemas/customers";
import { prisma } from "@dub/prisma";
import { prisma, sanitizeFullTextSearch } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
import { NextResponse } from "next/server";

Expand All @@ -15,25 +15,24 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => {
? { email }
: externalId
? { externalId }
: {
...(search && {
OR: [
{ email: { startsWith: search } },
{ externalId: { startsWith: search } },
{ name: { startsWith: search } },
],
}),
// only filter by country if not grouping by country
...(country &&
groupBy !== "country" && {
country,
}),
// only filter by linkId if not grouping by linkId
...(linkId &&
groupBy !== "linkId" && {
linkId,
}),
}),
: search
? search.includes("@")
? { email: search }
: {
email: { search: sanitizeFullTextSearch(search) },
name: { search: sanitizeFullTextSearch(search) },
}
: {}),
// only filter by country if not grouping by country
...(country &&
groupBy !== "country" && {
country,
}),
// only filter by linkId if not grouping by linkId
...(linkId &&
groupBy !== "linkId" && {
linkId,
}),
};

// Get customer count by country
Expand Down Expand Up @@ -63,6 +62,7 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => {
linkId: "desc",
},
},
take: 10000,
});

const links = await prisma.link.findMany({
Expand Down
31 changes: 15 additions & 16 deletions apps/web/app/(ee)/api/customers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
getCustomersQuerySchemaExtended,
} from "@/lib/zod/schemas/customers";
import { DiscountSchemaWithDeprecatedFields } from "@/lib/zod/schemas/discount";
import { prisma } from "@dub/prisma";
import { prisma, sanitizeFullTextSearch } from "@dub/prisma";
import { nanoid, R2_URL } from "@dub/utils";
import {
Customer,
Expand Down Expand Up @@ -65,21 +65,20 @@ export const GET = withWorkspace(
? { email }
: externalId
? { externalId }
: {
...(search && {
OR: [
{ email: { startsWith: search } },
{ externalId: { startsWith: search } },
{ name: { startsWith: search } },
],
}),
...(country && {
country,
}),
...(linkId && {
linkId,
}),
}),
: search
? search.includes("@")
? { email: search }
: {
email: { search: sanitizeFullTextSearch(search) },
name: { search: sanitizeFullTextSearch(search) },
}
: {}),
...(country && {
country,
}),
...(linkId && {
linkId,
}),
},
orderBy: {
[sortBy]: sortOrder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ export function usePayoutFilters() {
icon: InvoiceDollar,
label: "Invoice",
options: [],
hideInFilterDropdown: true,
},
],
[payoutsCount, partners, partnersAsync],
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/swr/use-customers-count.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function useCustomersCount<T = number>({
`/api/customers/count${getQueryString(
{ workspaceId, ...query },
{
include: ["linkId", "country", "search"],
include: ["linkId", "country", "search", "externalId"],
},
)}`,
fetcher,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/ui/customers/customer-table/customer-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,8 @@ export function CustomerTable() {
onRemove={onRemove}
/>
<SearchBoxPersisted
placeholder="Search by email, name, or external ID"
inputClassName="md:w-[21rem]"
placeholder="Search by email or name"
inputClassName="md:w-[16rem]"
/>
</div>
<AnimatedSizeContainer height>
Expand Down
20 changes: 16 additions & 4 deletions apps/web/ui/customers/customer-table/use-customer-filters.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import useCustomersCount from "@/lib/swr/use-customers-count";
import useWorkspace from "@/lib/swr/use-workspace";
import { LinkLogo, useRouterStuff } from "@dub/ui";
import { FlagWavy, Hyperlink } from "@dub/ui/icons";
import { FlagWavy, Hyperlink, SquareUserSparkle2 } from "@dub/ui/icons";
import { COUNTRIES, getApexDomain, getPrettyUrl, nFormatter } from "@dub/utils";
import { useCallback, useMemo } from "react";

Expand Down Expand Up @@ -93,16 +93,28 @@ export function useCustomerFilters(
}),
},
},
{
key: "externalId",
icon: SquareUserSparkle2,
label: "External ID",
options: [],
meta: {
filterParams: ({ getValue }) => ({
externalId: getValue(),
}),
},
},
],
[countriesCount, linksCount],
[countriesCount, linksCount, slug],
);

const activeFilters = useMemo(() => {
const { country, linkId } = searchParamsObj;
const { country, linkId, externalId } = searchParamsObj;

return [
...(country ? [{ key: "country", value: country }] : []),
...(linkId ? [{ key: "linkId", value: linkId }] : []),
...(externalId ? [{ key: "externalId", value: externalId }] : []),
];
}, [searchParamsObj]);

Expand All @@ -128,7 +140,7 @@ export function useCustomerFilters(
const onRemoveAll = useCallback(
() =>
queryParams({
del: ["country", "search"],
del: ["country", "linkId", "externalId", "search"],
}),
[queryParams],
);
Expand Down
3 changes: 2 additions & 1 deletion packages/prisma/schema/customer.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ model Customer {

@@unique([projectId, externalId])
@@unique([projectConnectId, externalId])
@@index([projectId, email])
@@index([projectId, createdAt])
@@index([projectId, saleAmount])
@@index([projectId, email, externalId, name])
@@index(externalId)
@@index(linkId)
@@index(country)
@@fulltext([email, name]) // For full-text search
}
4 changes: 3 additions & 1 deletion packages/ui/src/combobox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type ComboboxProps<
searchPlaceholder?: string;
emptyState?: ReactNode;
createLabel?: (search: string) => ReactNode;
createIcon?: Icon;
onCreate?: (search: string) => Promise<boolean>;
buttonProps?: ButtonProps;
labelProps?: { className?: string };
Expand Down Expand Up @@ -94,6 +95,7 @@ export function Combobox({
searchPlaceholder = "Search...",
emptyState,
createLabel,
createIcon: CreateIcon = Plus,
onCreate,
buttonProps,
labelProps,
Expand Down Expand Up @@ -289,7 +291,7 @@ export function Combobox({
{isCreating ? (
<LoadingSpinner className="size-4 shrink-0" />
) : (
<Plus className="size-4 shrink-0" />
<CreateIcon className="size-4 shrink-0" />
)}
<div className="grow truncate">
{createLabel?.(search) || `Create "${search}"`}
Expand Down
147 changes: 109 additions & 38 deletions packages/ui/src/filter/filter-list.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { cn, truncate } from "@dub/utils";
import { Command } from "cmdk";
import { X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { ReactNode, isValidElement } from "react";
import { ReactNode, isValidElement, useState } from "react";
import { AnimatedSizeContainer } from "../animated-size-container";
import { Combobox, ComboboxOption } from "../combobox";
import { useKeyboardShortcut } from "../hooks";
import { Icon } from "../icons";
import { Filter, FilterOption } from "./types";

type FilterListProps = {
Expand Down Expand Up @@ -190,44 +192,17 @@ export function FilterList({
);

return (
<Combobox
selected={selectedOption ?? null}
setSelected={(
newOption: ComboboxOption | null,
) => {
if (
newOption &&
newOption.value !== String(value)
) {
// Remove the current value and add the new one
onRemove(key, value);
onSelect(key, newOption.value);
}
}}
<FilterCombobox
key={`${key}-${value}`}
filter={filter}
value={value}
filterKey={key}
options={options}
optionRight={(option) => {
if (option.value === String(value)) {
return;
}
const filterOption = filter.options?.find(
(opt) =>
typeof String(opt.value) === "string" &&
typeof option.value === "string"
? String(opt.value).toLowerCase() ===
option.value.toLowerCase()
: String(opt.value) === option.value,
);
return filterOption ? (
<span className="ml-2 text-neutral-500">
{filterOption.right}
</span>
) : null;
}}
placeholder={truncate(optionLabel, 30)}
caret={false}
trigger={OptionDisplay({
className: "cursor-pointer hover:bg-neutral-50",
})}
selectedOption={selectedOption}
onRemove={onRemove}
onSelect={onSelect}
OptionDisplay={OptionDisplay}
optionLabel={optionLabel}
/>
);
})()
Expand Down Expand Up @@ -267,5 +242,101 @@ export function FilterList({
);
}

function FilterCombobox({
filter,
value,
filterKey,
options,
selectedOption,
onRemove,
onSelect,
OptionDisplay,
optionLabel,
}: {
filter: Filter;
value: FilterOption["value"];
filterKey: string;
options: ComboboxOption[];
selectedOption: ComboboxOption | undefined;
onRemove: (key: string, value: FilterOption["value"]) => void;
onSelect: (key: string, value: FilterOption["value"]) => void;
OptionDisplay: ({ className }: { className?: string }) => ReactNode;
optionLabel: string;
}) {
const [search, setSearch] = useState("");

// Check if filter has empty options array
const hasEmptyOptions = filter.options && filter.options.length === 0;

// Create emptyState based on CommandEmpty logic
const emptyState = (() => {
// If the filter has no options, show the search input as an option or "Start typing to search..."
if (hasEmptyOptions) {
if (!search) {
return (
<Command.Empty className="p-2 text-center text-sm text-neutral-400">
Start typing to search...
</Command.Empty>
);
}
// When search exists and filter has empty options, the onCreate handler will show the create option
return null; // onCreate will handle showing the option
}

return (
<Command.Empty className="p-2 text-center text-sm text-neutral-400">
No matching options
</Command.Empty>
);
})();

return (
<Combobox
selected={selectedOption ?? null}
setSelected={(newOption: ComboboxOption | null) => {
if (newOption && newOption.value !== String(value)) {
// Remove the current value and add the new one
onRemove(filterKey, value);
onSelect(filterKey, newOption.value);
}
}}
options={options}
onSearchChange={setSearch}
onCreate={
hasEmptyOptions && onSelect
? async (searchValue: string) => {
// Select the search value as a new option
onRemove(filterKey, value);
onSelect(filterKey, searchValue);
return true;
}
: undefined
}
createLabel={hasEmptyOptions ? (searchValue) => searchValue : undefined}
createIcon={filter.icon as Icon}
optionRight={(option) => {
if (option.value === String(value)) {
return;
}
const filterOption = filter.options?.find((opt) =>
typeof String(opt.value) === "string" &&
typeof option.value === "string"
? String(opt.value).toLowerCase() === option.value.toLowerCase()
: String(opt.value) === option.value,
);
return filterOption ? (
<span className="ml-2 text-neutral-500">{filterOption.right}</span>
) : null;
}}
placeholder={truncate(optionLabel, 30)}
caret={false}
emptyState={emptyState}
trigger={OptionDisplay({
className: "cursor-pointer hover:bg-neutral-50",
})}
/>
);
}

const isReactNode = (element: any): element is ReactNode =>
isValidElement(element);
Loading