Skip to content

Commit eabacae

Browse files
committed
Update customer page + add sortBy firstSaleAt/subscriptionCanceledAt
1 parent 4858f97 commit eabacae

File tree

7 files changed

+64
-36
lines changed

7 files changed

+64
-36
lines changed

apps/web/app/(ee)/api/customers/[id]/activity/route.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,16 @@ export const GET = withWorkspace(async ({ workspace, params }) => {
5252
? customer.createdAt.getTime() - customer.clickedAt.getTime()
5353
: null;
5454

55-
// Find the time to first sale of the customer
56-
// TODO: Calculate this from all events, not limited
57-
const firstSale = events.filter(({ event }) => event === "sale").pop();
58-
5955
const timeToSale =
60-
firstSale && customer.createdAt
61-
? new Date(firstSale.timestamp).getTime() - customer.createdAt.getTime()
56+
customer.firstSaleAt && customer.createdAt
57+
? customer.firstSaleAt.getTime() - customer.createdAt.getTime()
6258
: null;
6359

6460
return NextResponse.json(
6561
customerActivityResponseSchema.parse({
66-
ltv: customer.saleAmount,
62+
...customer,
6763
timeToLead,
6864
timeToSale,
69-
firstSaleDate: firstSale ? new Date(firstSale.timestamp) : null,
7065
events,
7166
link,
7267
}),

apps/web/lib/zod/schemas/customer-activity.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as z from "zod/v4";
22
import { LinkSchema } from "./links";
33

44
export const customerActivityResponseSchema = z.object({
5-
ltv: z.number(),
5+
saleAmount: z.number(),
66
timeToLead: z.number().nullable(),
77
timeToSale: z.number().nullable(),
8-
firstSaleDate: z.date().nullable(),
8+
firstSaleAt: z.date().nullable(),
9+
subscriptionCanceledAt: z.date().nullable(),
910
events: z.array(z.any()), // we've already parsed the events in get-customer-events.ts
1011
link: LinkSchema.pick({
1112
id: true,

apps/web/lib/zod/schemas/customers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ export const getCustomersQuerySchema = z
4646
"Whether to include expanded fields on the customer (`link`, `partner`, `discount`).",
4747
),
4848
sortBy: z
49-
.enum(["createdAt", "saleAmount"])
49+
.enum([
50+
"createdAt",
51+
"saleAmount",
52+
"firstSaleAt",
53+
"subscriptionCanceledAt",
54+
])
5055
.optional()
5156
.default("createdAt")
5257
.describe(

apps/web/ui/customers/customer-stats.tsx

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
currencyFormatter,
88
formatDateTimeSmart,
99
nFormatter,
10+
pluralize,
1011
} from "@dub/utils";
1112
import { formatDistance } from "date-fns";
1213
import Link from "next/link";
@@ -18,7 +19,11 @@ export function CustomerStats({
1819
}: {
1920
customer?: Pick<CustomerEnriched, "sales" | "saleAmount" | "createdAt">;
2021
}) {
21-
const { customerId } = useParams<{ customerId: string }>();
22+
const { slug: workspaceSlug, customerId } = useParams<{
23+
slug: string;
24+
customerId: string;
25+
}>();
26+
2227
const { customerActivity, isCustomerActivityLoading } = useCustomerActivity({
2328
customerId,
2429
});
@@ -29,49 +34,64 @@ export function CustomerStats({
2934
href?: string;
3035
}[] = useMemo(
3136
() => [
37+
{
38+
label: "First sale date",
39+
value:
40+
isCustomerActivityLoading ? undefined : customerActivity?.firstSaleAt ? (
41+
<TimestampTooltip
42+
timestamp={customerActivity.firstSaleAt}
43+
side="right"
44+
rows={["local", "utc"]}
45+
>
46+
<span className="hover:text-content-emphasis underline decoration-dotted underline-offset-2">
47+
{formatDateTimeSmart(customerActivity.firstSaleAt)}
48+
</span>
49+
</TimestampTooltip>
50+
) : (
51+
"-"
52+
),
53+
},
3254
{
3355
label: "Time to sale",
3456
value:
3557
!customer || isCustomerActivityLoading
3658
? undefined
37-
: customerActivity?.firstSaleDate
38-
? formatDistance(
39-
customerActivity.firstSaleDate,
40-
customer.createdAt,
41-
)
59+
: customerActivity?.firstSaleAt
60+
? formatDistance(customerActivity.firstSaleAt, customer.createdAt)
4261
: "-",
4362
},
4463
{
45-
label: "First sale date",
64+
label: "Lifetime value",
65+
value: customer ? (
66+
<div className="flex items-center gap-1">
67+
{currencyFormatter(customer.saleAmount ?? 0)}
68+
<span className="text-xs text-neutral-500">
69+
({nFormatter(customer.sales ?? 0, { full: true })}{" "}
70+
{pluralize("sale", customer.sales ?? 0)})
71+
</span>
72+
</div>
73+
) : undefined,
74+
href: `/${workspaceSlug}/events?event=sales&customerId=${customerId}&interval=1y`,
75+
},
76+
{
77+
label: "Subscription canceled",
4678
value:
47-
isCustomerActivityLoading ? undefined : customerActivity?.firstSaleDate ? (
79+
isCustomerActivityLoading ? undefined : customerActivity?.subscriptionCanceledAt ? (
4880
<TimestampTooltip
49-
timestamp={customerActivity.firstSaleDate}
81+
timestamp={customerActivity.subscriptionCanceledAt}
5082
side="right"
5183
rows={["local", "utc"]}
5284
>
5385
<span className="hover:text-content-emphasis underline decoration-dotted underline-offset-2">
54-
{formatDateTimeSmart(customerActivity.firstSaleDate)}
86+
{formatDateTimeSmart(customerActivity.subscriptionCanceledAt)}
5587
</span>
5688
</TimestampTooltip>
5789
) : (
5890
"-"
5991
),
6092
},
61-
{
62-
label: "Sales",
63-
value: customer
64-
? nFormatter(customer.sales ?? 0, { full: true })
65-
: undefined,
66-
},
67-
{
68-
label: "Lifetime value",
69-
value: customer
70-
? currencyFormatter(customer.saleAmount ?? 0)
71-
: undefined,
72-
},
7393
],
74-
[customer, customerActivity, isCustomerActivityLoading],
94+
[workspaceSlug, customer, customerActivity, isCustomerActivityLoading],
7595
);
7696

7797
return (

apps/web/ui/customers/customers-table/customers-table.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,12 @@ export function CustomersTable({
378378
onPaginationChange: setPagination,
379379
columnVisibility,
380380
onColumnVisibilityChange: setColumnVisibility,
381-
sortableColumns: ["createdAt", "saleAmount"],
381+
sortableColumns: [
382+
"createdAt",
383+
"saleAmount",
384+
"firstSaleAt",
385+
"subscriptionCanceledAt",
386+
],
382387
sortBy,
383388
sortOrder,
384389
onSortChange: ({ sortBy, sortOrder }) =>

apps/web/ui/layout/sidebar/app-sidebar-nav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ const NAV_AREAS: SidebarNavAreas<SidebarNavData> = {
293293
},
294294
{
295295
name: "Customers",
296-
icon: UserCheck,
296+
icon: User,
297297
href: `/${slug}/program/customers`,
298298
badge: pendingReferralsCount
299299
? pendingReferralsCount > 99

packages/prisma/schema/customer.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ model Customer {
3939
@@index([projectId, email])
4040
@@index([projectId, createdAt])
4141
@@index([projectId, saleAmount])
42+
@@index([projectId, firstSaleAt])
43+
@@index([projectId, subscriptionCanceledAt])
4244
@@index([programId, partnerId])
4345
@@index(partnerId)
4446
@@index(linkId)

0 commit comments

Comments
 (0)