Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
5f8670f
Add nav item + page
TWilson023 May 2, 2025
72c53ab
WIP table
TWilson023 May 2, 2025
7971421
Sort+filter
TWilson023 May 2, 2025
025c63d
Filtering
TWilson023 May 2, 2025
cc62c5d
Update customer-table.tsx
TWilson023 May 2, 2025
6e99050
Merge branch 'main' into customers-page
steven-tey May 4, 2025
960e456
add info tooltip
steven-tey May 4, 2025
2583d81
Merge branch 'main' into customers-page
steven-tey May 5, 2025
7505c12
Merge branch 'main' into customers-page
steven-tey May 5, 2025
0a64579
Fixes + LTV
TWilson023 May 5, 2025
3876215
Link link
TWilson023 May 5, 2025
8c6f854
Add linking w/ prefetch
TWilson023 May 5, 2025
0d03ba1
Update customer-details-column.tsx
TWilson023 May 5, 2025
f109d21
WIP plan restrictions
TWilson023 May 5, 2025
401b796
Add upgrade state
TWilson023 May 5, 2025
fe5f91f
Merge branch 'main' into customers-page
TWilson023 May 5, 2025
a707745
Make sales and salesAmount nullish
TWilson023 May 5, 2025
9b49c4c
Merge branch 'main' into customers-page
steven-tey May 5, 2025
4aa2377
Update customers.ts
TWilson023 May 5, 2025
a5b71ea
Merge branch 'customers-page' of github.com:dubinc/dub into customers…
TWilson023 May 5, 2025
3e76d11
add filter by linkId
steven-tey May 5, 2025
0f00c31
Update index.test.ts
TWilson023 May 5, 2025
c2a96ef
FilterButtonTableRow
steven-tey May 5, 2025
776988d
Merge branch 'customers-page' of https://github.com/dubinc/dub into c…
steven-tey May 5, 2025
2fd849c
CustomerTable final touches
steven-tey May 5, 2025
421151b
toLocaleString pagination + add indexes
steven-tey May 5, 2025
336135d
getApexDomain of destination URL
steven-tey May 5, 2025
eabfa4e
final fixes
steven-tey May 5, 2025
134c49a
filter out linkIds that don't exist
steven-tey May 5, 2025
efad578
Merge branch 'main' into customers-page
steven-tey May 5, 2025
ec7096f
Merge branch 'main' into customers-page
steven-tey May 5, 2025
30b6d99
Merge branch 'main' into customers-page
steven-tey May 6, 2025
645492d
Merge branch 'main' into customers-page
steven-tey May 6, 2025
9891ac1
Update customer-table.tsx
steven-tey May 6, 2025
8c362e7
Merge branch 'main' into customers-page
steven-tey May 6, 2025
83f5853
Update customer-table.tsx
devkiran May 6, 2025
07422bf
Merge branch 'main' into customers-page
steven-tey May 6, 2025
8d4c2f6
update search input
steven-tey May 6, 2025
3548a56
Merge branch 'main' into customers-page
steven-tey May 7, 2025
33f1d0d
Merge branch 'main' into customers-page
steven-tey May 7, 2025
5c0da1f
Merge branch 'main' into customers-page
steven-tey May 7, 2025
631233d
Merge branch 'main' into customers-page
steven-tey May 7, 2025
8a226c2
Merge branch 'main' into customers-page
steven-tey May 8, 2025
41a1d3d
Merge branch 'main' into customers-page
steven-tey May 8, 2025
28dc8a4
Update customer-table.tsx
steven-tey May 8, 2025
87fcf7e
Merge branch 'customers-page' of https://github.com/dubinc/dub into c…
steven-tey May 8, 2025
efd9adb
Update sheet.tsx
steven-tey May 8, 2025
1f06609
update empty state
steven-tey May 8, 2025
7fed402
Update customer-table.tsx
steven-tey May 8, 2025
2eb386d
Referral link → Link
steven-tey May 8, 2025
44159a0
Update package.json
steven-tey May 8, 2025
eb750a5
finalize pricing item
steven-tey May 8, 2025
464cf88
Merge pull request #2387 from dubinc/customers-page
steven-tey May 8, 2025
faf8850
Update OpenAPI descriptions for tag IDs and tag names
devkiran May 8, 2025
7d3fd6c
Merge pull request #2402 from dubinc/fix-tags-api
steven-tey May 8, 2025
31d5336
fix program-application-reminder cron delay
steven-tey May 8, 2025
6fa0b49
Merge branch 'main' of https://github.com/dubinc/dub
steven-tey May 8, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function POST(req: Request) {
await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/program-application-reminder`,
// repeat every 24 hours, but it'll be cancelled if the application is more than 3 days old or is associated with a partner
delay: 24 * 60 * 60 * 1000,
delay: 24 * 60 * 60,
body: {
applicationId: application.id,
},
Expand Down
98 changes: 81 additions & 17 deletions apps/web/app/api/customers/count/route.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,94 @@
import { withWorkspace } from "@/lib/auth";
import { getCustomersCountQuerySchema } from "@/lib/zod/schemas/customers";
import { prisma } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
import { NextResponse } from "next/server";

// GET /api/customers/count
export const GET = withWorkspace(async ({ workspace, searchParams }) => {
const { email, externalId, search } =
const { email, externalId, search, country, linkId, groupBy } =
getCustomersCountQuerySchema.parse(searchParams);

const commonWhere: Prisma.CustomerWhereInput = {
projectId: workspace.id,
...(email
? { email }
: externalId
? { externalId }
: {
...(search && {
OR: [
{ email: { startsWith: search } },
{ externalId: { startsWith: search } },
{ name: { startsWith: search } },
],
}),
...(country && {
country,
}),
...(linkId && {
linkId,
}),
}),
};

// Get customer count by country
if (groupBy === "country") {
const data = await prisma.customer.groupBy({
by: ["country"],
where: commonWhere,
_count: true,
orderBy: {
_count: {
country: "desc",
},
},
});

return NextResponse.json(data);
}

// Get customer count by linkId
if (groupBy === "linkId") {
const data = await prisma.customer.groupBy({
by: ["linkId"],
where: { ...commonWhere, linkId: { not: null } },
_count: true,
orderBy: {
_count: {
linkId: "desc",
},
},
});

const links = await prisma.link.findMany({
where: {
id: { in: data.map(({ linkId }) => linkId!) },
},
select: {
id: true,
shortLink: true,
url: true,
},
});

const enrichedData = data
.map((d) => {
const link = links.find(({ id }) => id === d.linkId);
if (!link) return null;
return {
...d,
shortLink: link?.shortLink,
url: link?.url,
};
})
.filter(Boolean);

return NextResponse.json(enrichedData);
}

const count = await prisma.customer.count({
where: {
projectId: workspace.id,
...(email
? { email }
: externalId
? { externalId }
: search
? {
OR: [
{ email: { startsWith: search } },
{ externalId: { startsWith: search } },
{ name: { startsWith: search } },
],
}
: {}),
},
where: commonWhere,
});

return NextResponse.json(count);
Expand Down
20 changes: 15 additions & 5 deletions apps/web/app/api/customers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ export const GET = withWorkspace(
email,
externalId,
search,
country,
linkId,
includeExpandedFields,
page,
pageSize,
customerIds,
sortBy,
sortOrder,
} = getCustomersQuerySchemaExtended.parse(searchParams);

const customers = (await prisma.customer.findMany({
Expand All @@ -64,18 +68,24 @@ export const GET = withWorkspace(
? { email }
: externalId
? { externalId }
: search
? {
: {
...(search && {
OR: [
{ email: { startsWith: search } },
{ externalId: { startsWith: search } },
{ name: { startsWith: search } },
],
}
: {}),
}),
...(country && {
country,
}),
...(linkId && {
linkId,
}),
}),
},
orderBy: {
createdAt: "desc",
[sortBy]: sortOrder,
},
skip: (page - 1) * pageSize,
take: pageSize,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { CustomerTable } from "@/ui/customers/customer-table/customer-table";

export function CustomersPageClient() {
return (
<div className="mt-3">
<CustomerTable />
</div>
);
}
13 changes: 13 additions & 0 deletions apps/web/app/app.dub.co/(dashboard)/[slug]/customers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PageContent } from "@/ui/layout/page-content";
import { MaxWidthWrapper } from "@dub/ui";
import { CustomersPageClient } from "./page-client";

export default function CustomersPage() {
return (
<PageContent title="Customers">
<MaxWidthWrapper>
<CustomersPageClient />
</MaxWidthWrapper>
</PageContent>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import useCommissionsCount from "@/lib/swr/use-commissions-count";
import useWorkspace from "@/lib/swr/use-workspace";
import { CommissionResponse } from "@/lib/types";
import FilterButton from "@/ui/analytics/events/filter-button";
import { CustomerRowItem } from "@/ui/customers/customer-row-item";
import { CommissionRowMenu } from "@/ui/partners/commission-row-menu";
import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges";
import { CommissionTypeBadge } from "@/ui/partners/commission-type-badge";
import { PartnerRowItem } from "@/ui/partners/partner-row-item";
import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state";
import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row";
import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker";
import {
AnimatedSizeContainer,
Expand Down Expand Up @@ -188,7 +188,9 @@ const CommissionTableInner = memo(

return (
!limit &&
meta?.filterParams && <FilterButton set={meta.filterParams(cell)} />
meta?.filterParams && (
<FilterButtonTableRow set={meta.filterParams(cell)} />
)
);
},
...(!limit && {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import usePartnerEarningsCount from "@/lib/swr/use-partner-earnings-count";
import useProgramEnrollment from "@/lib/swr/use-program-enrollment";
import { PartnerEarningsResponse } from "@/lib/types";
import FilterButton from "@/ui/analytics/events/filter-button";
import { CustomerRowItem } from "@/ui/customers/customer-row-item";
import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges";
import { CommissionTypeBadge } from "@/ui/partners/commission-type-badge";
import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state";
import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row";
import {
CopyText,
LinkLogo,
Expand Down Expand Up @@ -191,7 +191,9 @@ export function EarningsTablePartner({ limit }: { limit?: number }) {
cellRight: (cell) => {
const meta = cell.column.columnDef.meta as ColumnMeta | undefined;
return (
meta?.filterParams && <FilterButton set={meta.filterParams(cell)} />
meta?.filterParams && (
<FilterButtonTableRow set={meta.filterParams(cell)} />
)
);
},
...(!limit && {
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/middleware/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export default async function AppMiddleware(req: NextRequest) {
"/links",
"/analytics",
"/events",
"/customers",
"/programs",
"/settings",
"/upgrade",
Expand Down
5 changes: 0 additions & 5 deletions apps/web/lib/middleware/utils/app-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ export const appRedirect = (path: string) => {
if (upgradeRegex.test(path))
return path.replace(upgradeRegex, "/$1/settings/billing/upgrade");

// Redirect "/[slug]/customers" to "/[slug]/events?event=leads" for now
const customersRegex = /^\/([^\/]+)\/customers$/;
if (customersRegex.test(path))
return path.replace(customersRegex, "/$1/events?event=leads");

// Redirect "programs/[programId]/settings" to "programs/[programId]/settings/rewards" (first tab)
const programSettingsRegex = /\/programs\/([^\/]+)\/settings$/;
if (programSettingsRegex.test(path))
Expand Down
6 changes: 3 additions & 3 deletions apps/web/lib/plan-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export const getPlanCapabilities = (
plan: WorkspaceProps["plan"] | undefined | string,
) => {
return {
canAddFolder: plan && !["free"].includes(plan),
canManageFolderPermissions: plan && !["free", "pro"].includes(plan), // default access level is write
canManageCustomers: plan && !["free", "pro"].includes(plan),
canAddFolder: !!plan && !["free"].includes(plan),
canManageFolderPermissions: !!plan && !["free", "pro"].includes(plan), // default access level is write
canManageCustomers: !!plan && !["free", "pro"].includes(plan),
};
};
16 changes: 13 additions & 3 deletions apps/web/lib/swr/use-customers-count.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { fetcher } from "@dub/utils";
import useSWR from "swr";
import { z } from "zod";
import { getCustomersCountQuerySchema } from "../zod/schemas/customers";
import useWorkspace from "./use-workspace";

export default function useCustomersCount() {
export default function useCustomersCount<T = number>({
query,
enabled = true,
}: {
query?: z.infer<typeof getCustomersCountQuerySchema>;
enabled?: boolean;
} = {}) {
const { id: workspaceId } = useWorkspace();

const { data, error } = useSWR<number>(
workspaceId && `/api/customers/count?workspaceId=${workspaceId}`,
const { data, error } = useSWR<T>(
enabled &&
workspaceId &&
`/api/customers/count?${new URLSearchParams({ workspaceId, ...query })}`,
fetcher,
);

Expand Down
48 changes: 43 additions & 5 deletions apps/web/lib/zod/schemas/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,36 @@ export const getCustomersQuerySchema = z
.describe(
"A search query to filter customers by email, externalId, or name. If `email` or `externalId` is provided, this will be ignored.",
),
country: z
.string()
.optional()
.describe(
"A filter on the list based on the customer's `country` field.",
),
linkId: z
.string()
.optional()
.describe(
"A filter on the list based on the customer's `linkId` field (the referral link ID).",
),
includeExpandedFields: booleanQuerySchema
.optional()
.describe(
"Whether to include expanded fields on the customer (`link`, `partner`, `discount`).",
),

sortBy: z
.enum(["createdAt", "saleAmount"])
.optional()
.default("createdAt")
.describe(
"The field to sort the customers by. The default is `createdAt`.",
),
sortOrder: z
.enum(["asc", "desc"])
.optional()
.default("desc")
.describe("The sort order. The default is `desc`."),
})
.merge(getPaginationQuerySchema({ pageSize: CUSTOMERS_MAX_PAGE_SIZE }));

Expand All @@ -44,11 +69,15 @@ export const getCustomersQuerySchemaExtended = getCustomersQuerySchema.merge(
}),
);

export const getCustomersCountQuerySchema = getCustomersQuerySchema.omit({
includeExpandedFields: true,
page: true,
pageSize: true,
});
export const getCustomersCountQuerySchema = getCustomersQuerySchema
.omit({
includeExpandedFields: true,
page: true,
pageSize: true,
sortBy: true,
sortOrder: true,
})
.extend({ groupBy: z.enum(["country", "linkId"]).optional() });

export const createCustomerBodySchema = z.object({
email: z
Expand Down Expand Up @@ -87,6 +116,14 @@ export const CustomerSchema = z.object({
email: z.string().nullish().describe("Email of the customer."),
avatar: z.string().nullish().describe("Avatar URL of the customer."),
country: z.string().nullish().describe("Country of the customer."),
sales: z
.number()
.nullish()
.describe("Total number of sales for the customer."),
saleAmount: z
.number()
.nullish()
.describe("Total amount of sales for the customer."),
createdAt: z.date().describe("The date the customer was created."),
});

Expand All @@ -97,6 +134,7 @@ export const CustomerEnrichedSchema = CustomerSchema.extend({
domain: true,
key: true,
shortLink: true,
url: true,
programId: true,
}).nullish(),
programId: z.string().nullish(),
Expand Down
Loading