diff --git a/apps/web/app/api/customers/[id]/route.ts b/apps/web/app/api/customers/[id]/route.ts
index 80714421282..8e37f7fe909 100644
--- a/apps/web/app/api/customers/[id]/route.ts
+++ b/apps/web/app/api/customers/[id]/route.ts
@@ -55,7 +55,12 @@ export const PATCH = withWorkspace(
include: {
link: {
include: {
- programEnrollment: true,
+ programEnrollment: {
+ include: {
+ partner: true,
+ discount: true,
+ },
+ },
},
},
},
diff --git a/apps/web/app/api/customers/route.ts b/apps/web/app/api/customers/route.ts
index fe66a5b99a4..62cfbefe7a1 100644
--- a/apps/web/app/api/customers/route.ts
+++ b/apps/web/app/api/customers/route.ts
@@ -51,13 +51,6 @@ export const POST = withWorkspace(
projectId: workspace.id,
projectConnectId: workspace.stripeConnectId,
},
- include: {
- link: {
- include: {
- programEnrollment: true,
- },
- },
- },
});
return NextResponse.json(
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/page-client.tsx
index ee9340f6f13..2f0d21a068d 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/page-client.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/page-client.tsx
@@ -48,6 +48,7 @@ export default function ProgramOverviewPageClient() {
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/settings/program-settings.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/settings/program-settings.tsx
index 47ea23d59a4..adaadb0ab9e 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/settings/program-settings.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/settings/program-settings.tsx
@@ -8,7 +8,7 @@ import { EmbedDocsSheet } from "@/ui/partners/embed-docs-sheet";
import { ProgramCommissionDescription } from "@/ui/partners/program-commission-description";
import { AnimatedSizeContainer, Button } from "@dub/ui";
import { CircleCheckFill, Code, LoadingSpinner } from "@dub/ui/icons";
-import { cn, pluralize } from "@dub/utils";
+import { cn, INFINITY_NUMBER, pluralize } from "@dub/utils";
import { useAction } from "next-safe-action/hooks";
import { useState } from "react";
import {
@@ -52,11 +52,10 @@ export function ProgramSettings() {
type FormData = Pick<
ProgramProps,
- | "recurringCommission"
- | "recurringDuration"
- | "isLifetimeRecurring"
- | "commissionType"
| "commissionAmount"
+ | "commissionType"
+ | "commissionDuration"
+ | "commissionInterval"
>;
function ProgramSettingsForm({ program }: { program: ProgramProps }) {
@@ -66,10 +65,7 @@ function ProgramSettingsForm({ program }: { program: ProgramProps }) {
const form = useForm({
mode: "onBlur",
defaultValues: {
- recurringCommission: program.recurringCommission,
- recurringDuration: program.recurringDuration,
- isLifetimeRecurring: program.isLifetimeRecurring,
- commissionType: program.commissionType,
+ ...program,
commissionAmount:
program.commissionType === "flat"
? program.commissionAmount / 100
@@ -87,15 +83,15 @@ function ProgramSettingsForm({ program }: { program: ProgramProps }) {
} = form;
const [
- recurringCommission,
- recurringDuration,
- isLifetimeRecurring,
+ commissionAmount,
commissionType,
+ commissionDuration,
+ commissionInterval,
] = watch([
- "recurringCommission",
- "recurringDuration",
- "isLifetimeRecurring",
+ "commissionAmount",
"commissionType",
+ "commissionDuration",
+ "commissionInterval",
]);
const { executeAsync } = useAction(updateProgramAction, {
@@ -157,62 +153,67 @@ function ProgramSettingsForm({ program }: { program: ProgramProps }) {
>
- {commissionTypes.map((commissionType) => (
-
- ))}
+ >
+
{
+ if (e.target.checked) {
+ setValue(
+ "commissionDuration",
+ commissionType.recurring ? 12 : 1,
+ { shouldDirty: true },
+ );
+ }
+ }}
+ />
+
+
+ {commissionType.label}
+
+ {commissionType.description}
+
+
+
+ );
+ })}
1
+ ? "h-auto"
+ : "h-0 opacity-0",
)}
- aria-hidden={!recurringCommission}
- {...{ inert: !recurringCommission ? "" : undefined }}
+ aria-hidden={
+ !(commissionDuration && commissionDuration > 1)
+ }
+ {...{
+ inert: !(commissionDuration && commissionDuration > 1),
+ }}
>
-
+
@@ -349,7 +334,7 @@ function Summary({ program }: { program: ProgramProps }) {
...program,
commissionAmount:
program.commissionType === "flat"
- ? program.commissionAmount / 100
+ ? program.commissionAmount * 100
: program.commissionAmount,
},
}) as FormData;
@@ -365,7 +350,6 @@ function Summary({ program }: { program: ProgramProps }) {
Referral link
diff --git a/apps/web/app/app.dub.co/embed/inline/page.tsx b/apps/web/app/app.dub.co/embed/inline/page.tsx
index d0042127d14..98b256e1479 100644
--- a/apps/web/app/app.dub.co/embed/inline/page.tsx
+++ b/apps/web/app/app.dub.co/embed/inline/page.tsx
@@ -8,7 +8,9 @@ export default async function EmbedInlinePage({
}) {
const { token } = searchParams;
- const { link, program } = await getEmbedData(token);
+ const { link, program, discount } = await getEmbedData(token);
- return ;
+ return (
+
+ );
}
diff --git a/apps/web/app/app.dub.co/embed/utils.ts b/apps/web/app/app.dub.co/embed/utils.ts
index fb4d326a6fc..55210bda100 100644
--- a/apps/web/app/app.dub.co/embed/utils.ts
+++ b/apps/web/app/app.dub.co/embed/utils.ts
@@ -1,4 +1,5 @@
import { embedToken } from "@/lib/embed/embed-token";
+import { DiscountSchema } from "@/lib/zod/schemas/discount";
import { prisma } from "@dub/prisma";
import { notFound } from "next/navigation";
@@ -17,11 +18,7 @@ export const getEmbedData = async (token: string) => {
program: true,
programEnrollment: {
select: {
- partner: {
- select: {
- users: true,
- },
- },
+ discount: true,
},
},
},
@@ -39,12 +36,10 @@ export const getEmbedData = async (token: string) => {
return {
program,
- // check if the user has an active profile on Dub Partners
- hasPartnerProfile:
- programEnrollment && programEnrollment.partner.users.length > 0
- ? true
- : false,
link,
+ discount: programEnrollment?.discount
+ ? DiscountSchema.parse(programEnrollment?.discount)
+ : null,
earnings:
(program.commissionType === "percentage" ? link.saleAmount : link.sales) *
(program.commissionAmount / 100),
diff --git a/apps/web/app/partners.dub.co/(apply)/apply/[programSlug]/details-grid.tsx b/apps/web/app/partners.dub.co/(apply)/apply/[programSlug]/details-grid.tsx
index c0a5f72e65f..a88ebf0ea7a 100644
--- a/apps/web/app/partners.dub.co/(apply)/apply/[programSlug]/details-grid.tsx
+++ b/apps/web/app/partners.dub.co/(apply)/apply/[programSlug]/details-grid.tsx
@@ -1,6 +1,6 @@
import { Program } from "@dub/prisma/client";
import { Calendar6, MoneyBills2 } from "@dub/ui/icons";
-import { cn, currencyFormatter } from "@dub/utils";
+import { cn, currencyFormatter, INFINITY_NUMBER } from "@dub/utils";
export function DetailsGrid({
program,
@@ -26,9 +26,10 @@ export function DetailsGrid({
{
icon: Calendar6,
title: "Duration",
- value: program.isLifetimeRecurring
- ? "Lifetime"
- : `${program.recurringDuration} ${program.recurringInterval}s`,
+ value:
+ program.commissionDuration === INFINITY_NUMBER
+ ? "Lifetime"
+ : `${program.commissionDuration} ${program.commissionInterval}s`,
},
].map(({ icon: Icon, title, value }) => (
diff --git a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/page-client.tsx b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/page-client.tsx
index e5992c72f82..04adb27bc90 100644
--- a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/page-client.tsx
+++ b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/page-client.tsx
@@ -69,7 +69,10 @@ export default function ProgramPageClient() {
{program ? (
-
+
) : (
)}
diff --git a/apps/web/lib/actions/create-program.ts b/apps/web/lib/actions/create-program.ts
index 74eb86bd530..da0e2d5c104 100644
--- a/apps/web/lib/actions/create-program.ts
+++ b/apps/web/lib/actions/create-program.ts
@@ -16,10 +16,8 @@ export const createProgramAction = authActionClient
name,
commissionType,
commissionAmount,
- recurringCommission,
- recurringDuration,
- recurringInterval,
- isLifetimeRecurring,
+ commissionDuration,
+ commissionInterval,
cookieLength,
domain,
} = parsedInput;
@@ -40,10 +38,8 @@ export const createProgramAction = authActionClient
slug: slugify(name),
commissionType,
commissionAmount,
- recurringCommission,
- recurringDuration,
- recurringInterval,
- isLifetimeRecurring,
+ commissionDuration,
+ commissionInterval,
cookieLength,
domain,
},
diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts
index b98df10d58d..1b7d8812435 100644
--- a/apps/web/lib/actions/partners/accept-program-invite.ts
+++ b/apps/web/lib/actions/partners/accept-program-invite.ts
@@ -18,6 +18,13 @@ export const acceptProgramInviteAction = authPartnerActionClient
const programInvite = await prisma.programInvite.findUniqueOrThrow({
where: { id: programInviteId },
+ include: {
+ program: {
+ select: {
+ discounts: true,
+ },
+ },
+ },
});
// enroll partner in program and delete the invite
@@ -29,6 +36,7 @@ export const acceptProgramInviteAction = authPartnerActionClient
linkId: programInvite.linkId,
partnerId: partner.id,
status: "approved",
+ discountId: programInvite.program.discounts[0].id,
},
}),
prisma.programInvite.delete({
diff --git a/apps/web/lib/actions/partners/approve-partner.ts b/apps/web/lib/actions/partners/approve-partner.ts
index d61d2fe2a5f..2b9e26f3e5f 100644
--- a/apps/web/lib/actions/partners/approve-partner.ts
+++ b/apps/web/lib/actions/partners/approve-partner.ts
@@ -1,6 +1,7 @@
"use server";
import { prisma } from "@dub/prisma";
+import { waitUntil } from "@vercel/functions";
import { getLinkOrThrow } from "../../api/links/get-link-or-throw";
import { getProgramOrThrow } from "../../api/programs/get-program";
import { recordLink } from "../../tinybird";
@@ -21,60 +22,33 @@ export const approvePartnerAction = authActionClient
const { workspace } = ctx;
const { programId, partnerId, linkId } = parsedInput;
- const [program, link, tags, programEnrollment] = await Promise.all([
+ const [program, link] = await Promise.all([
getProgramOrThrow({
workspaceId: workspace.id,
programId,
}),
-
getLinkOrThrow({
workspaceId: workspace.id,
linkId,
}),
-
- prisma.tag.findMany({
- where: {
- links: {
- some: {
- linkId,
- },
- },
- },
- }),
-
- prisma.programEnrollment.findUnique({
- where: {
- partnerId_programId: {
- partnerId,
- programId,
- },
- },
- include: {
- partner: true,
- },
- }),
]);
- if (!programEnrollment) {
- throw new Error("Program enrollment not found.");
- }
-
- if (programEnrollment.status !== "pending") {
- throw new Error("Program enrollment is not pending.");
- }
-
if (link.programId) {
throw new Error("Link is already associated with another partner.");
}
- await Promise.allSettled([
+ const [_, updatedLink] = await Promise.all([
prisma.programEnrollment.update({
where: {
- id: programEnrollment.id,
+ partnerId_programId: {
+ partnerId,
+ programId,
+ },
},
data: {
status: "approved",
linkId: link.id,
+ discountId: program?.discounts?.[0]?.id || null,
},
}),
@@ -86,21 +60,25 @@ export const approvePartnerAction = authActionClient
data: {
programId,
},
+ include: {
+ tags: true,
+ },
}),
+ ]);
- // record link update in tinybird
+ waitUntil(
recordLink({
- domain: link.domain,
- key: link.key,
- link_id: link.id,
- created_at: link.createdAt,
- url: link.url,
- tag_ids: tags.map((t) => t.id) || [],
+ domain: updatedLink.domain,
+ key: updatedLink.key,
+ link_id: updatedLink.id,
+ created_at: updatedLink.createdAt,
+ url: updatedLink.url,
+ tag_ids: updatedLink.tags.map((t) => t.tagId),
program_id: program.id,
workspace_id: workspace.id,
deleted: false,
}),
- ]);
+ );
// TODO: [partners] Notify partner of approval?
diff --git a/apps/web/lib/actions/update-program.ts b/apps/web/lib/actions/update-program.ts
index 90b955379ce..0507994d466 100644
--- a/apps/web/lib/actions/update-program.ts
+++ b/apps/web/lib/actions/update-program.ts
@@ -20,10 +20,8 @@ export const updateProgramAction = authActionClient
name,
commissionType,
commissionAmount,
- recurringCommission,
- recurringDuration,
- recurringInterval,
- isLifetimeRecurring,
+ commissionDuration,
+ commissionInterval,
cookieLength,
domain,
url,
@@ -42,10 +40,8 @@ export const updateProgramAction = authActionClient
name,
commissionType,
commissionAmount,
- recurringCommission,
- recurringDuration,
- recurringInterval,
- isLifetimeRecurring,
+ commissionDuration,
+ commissionInterval,
cookieLength,
domain,
url,
diff --git a/apps/web/lib/api/customers/get-customer-or-throw.ts b/apps/web/lib/api/customers/get-customer-or-throw.ts
index 510986bc5ed..95795a0e7a2 100644
--- a/apps/web/lib/api/customers/get-customer-or-throw.ts
+++ b/apps/web/lib/api/customers/get-customer-or-throw.ts
@@ -24,7 +24,20 @@ export const getCustomerOrThrow = async (
: { id }),
},
...(expand?.includes("link")
- ? { include: { link: { include: { programEnrollment: true } } } }
+ ? {
+ include: {
+ link: {
+ include: {
+ programEnrollment: {
+ include: {
+ partner: true,
+ discount: true,
+ },
+ },
+ },
+ },
+ },
+ }
: {}),
});
diff --git a/apps/web/lib/api/customers/transform-customer.ts b/apps/web/lib/api/customers/transform-customer.ts
index 366b06f202f..e146ccce7ce 100644
--- a/apps/web/lib/api/customers/transform-customer.ts
+++ b/apps/web/lib/api/customers/transform-customer.ts
@@ -1,22 +1,30 @@
-import { Customer, Link, ProgramEnrollment } from "@dub/prisma/client";
+import {
+ Customer,
+ Discount,
+ Link,
+ Partner,
+ ProgramEnrollment,
+} from "@dub/prisma/client";
export interface CustomerWithLink extends Customer {
link?:
| (Link & {
- programEnrollment?: ProgramEnrollment | null;
+ programEnrollment?:
+ | (ProgramEnrollment & {
+ partner: Partner;
+ discount: Discount | null;
+ })
+ | null;
})
| null;
}
export const transformCustomer = (customer: CustomerWithLink) => {
+ const programEnrollment = customer.link?.programEnrollment;
return {
...customer,
- partner: customer.link?.programEnrollment
- ? {
- id: customer.link.programEnrollment.partnerId,
- shortLink: customer.link.shortLink,
- couponId: customer.link.programEnrollment.couponId,
- }
- : null,
+ link: customer.link || null,
+ partner: programEnrollment?.partner || null,
+ discount: programEnrollment?.discount || null,
};
};
diff --git a/apps/web/lib/api/links/get-link-or-throw.ts b/apps/web/lib/api/links/get-link-or-throw.ts
index c0b42cadd22..9b04ca1f796 100644
--- a/apps/web/lib/api/links/get-link-or-throw.ts
+++ b/apps/web/lib/api/links/get-link-or-throw.ts
@@ -10,7 +10,7 @@ interface GetLinkParams {
key?: string;
}
-// Find link
+// Get link or throw error if not found or doesn't belong to workspace
export const getLinkOrThrow = async (params: GetLinkParams) => {
let { workspaceId, domain, key, externalId } = params;
let link: Link | null = null;
diff --git a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
index 9b8817dc659..f6ddb2e868f 100644
--- a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
+++ b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
@@ -1,4 +1,5 @@
import { prisma } from "@dub/prisma";
+import { Prisma } from "@dub/prisma/client";
import { DubApiError } from "../errors";
export async function getProgramEnrollmentOrThrow({
@@ -8,6 +9,12 @@ export async function getProgramEnrollmentOrThrow({
partnerId: string;
programId: string;
}) {
+ const include: Prisma.ProgramEnrollmentInclude = {
+ program: true,
+ link: true,
+ discount: true,
+ };
+
const programEnrollment = programId.startsWith("prog_")
? await prisma.programEnrollment.findUnique({
where: {
@@ -16,10 +23,7 @@ export async function getProgramEnrollmentOrThrow({
programId,
},
},
- include: {
- program: true,
- link: true,
- },
+ include,
})
: await prisma.programEnrollment.findFirst({
where: {
@@ -28,10 +32,7 @@ export async function getProgramEnrollmentOrThrow({
slug: programId,
},
},
- include: {
- program: true,
- link: true,
- },
+ include,
});
if (!programEnrollment || !programEnrollment.program) {
diff --git a/apps/web/lib/api/programs/get-program.ts b/apps/web/lib/api/programs/get-program.ts
index 2950b8d56e5..d8d6b132a67 100644
--- a/apps/web/lib/api/programs/get-program.ts
+++ b/apps/web/lib/api/programs/get-program.ts
@@ -14,6 +14,9 @@ export const getProgramOrThrow = async ({
id: programId,
workspaceId,
},
+ include: {
+ discounts: true,
+ },
});
if (!program) {
diff --git a/apps/web/lib/api/sales/create-sale-data.ts b/apps/web/lib/api/sales/create-sale-data.ts
index b83ef21c5e3..fdfefa117e1 100644
--- a/apps/web/lib/api/sales/create-sale-data.ts
+++ b/apps/web/lib/api/sales/create-sale-data.ts
@@ -1,4 +1,5 @@
import { Prisma, Program, SaleStatus } from "@dub/prisma/client";
+import { INFINITY_NUMBER } from "@dub/utils";
import { createId } from "../utils";
import { calculateEarnings } from "./calculate-earnings";
@@ -52,14 +53,18 @@ export const createSaleData = ({
currency: sale.currency,
partnerId: partner.id,
programId: program.id,
- commissionAmount,
- commissionType: program.commissionType,
- recurringCommission: program.recurringCommission,
- recurringDuration: program.recurringDuration,
- recurringInterval: program.recurringInterval,
- isLifetimeRecurring: program.isLifetimeRecurring,
status: SaleStatus.pending,
earnings,
metadata: metadata || Prisma.JsonNull,
+ // TODO: remove these
+ commissionAmount,
+ commissionType: program.commissionType,
+ recurringCommission:
+ program.commissionDuration && program.commissionDuration > 1
+ ? true
+ : false,
+ recurringDuration: program.commissionDuration,
+ recurringInterval: program.commissionInterval,
+ isLifetimeRecurring: program.commissionDuration === INFINITY_NUMBER,
};
};
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index 2d67305dafc..ad7553dca04 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -18,6 +18,7 @@ import {
CustomerSchema,
} from "./zod/schemas/customers";
import { dashboardSchema } from "./zod/schemas/dashboard";
+import { DiscountSchema } from "./zod/schemas/discount";
import { integrationSchema } from "./zod/schemas/integration";
import { InvoiceSchema } from "./zod/schemas/invoices";
import {
@@ -340,6 +341,8 @@ export type PartnerProps = z.infer
;
export type EnrolledPartnerProps = z.infer;
+export type DiscountProps = z.infer;
+
export type ProgramProps = z.infer;
export type ProgramInviteProps = z.infer;
diff --git a/apps/web/lib/zod/schemas/customers.ts b/apps/web/lib/zod/schemas/customers.ts
index eda22a1ccb5..4afe50ce171 100644
--- a/apps/web/lib/zod/schemas/customers.ts
+++ b/apps/web/lib/zod/schemas/customers.ts
@@ -1,4 +1,5 @@
import z from "@/lib/zod";
+import { DiscountSchema } from "./discount";
import { LinkSchema } from "./links";
import { getPaginationQuerySchema } from "./misc";
@@ -37,13 +38,21 @@ export const CustomerSchema = z.object({
avatar: z.string().nullish().describe("Avatar URL of the customer."),
country: z.string().nullish().describe("Country of the customer."),
createdAt: z.date().describe("The date the customer was created."),
+ link: LinkSchema.pick({
+ id: true,
+ domain: true,
+ key: true,
+ shortLink: true,
+ }).nullish(),
partner: z
.object({
- id: z.string().nullish(),
- shortLink: z.string().nullish(),
- couponId: z.string().nullish(),
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ image: z.string().nullish(),
})
.nullish(),
+ discount: DiscountSchema.nullish(),
});
export const CUSTOMERS_MAX_PAGE_SIZE = 100;
diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts
new file mode 100644
index 00000000000..b50198b6d53
--- /dev/null
+++ b/apps/web/lib/zod/schemas/discount.ts
@@ -0,0 +1,12 @@
+import { CommissionInterval, CommissionType } from "@dub/prisma/client";
+import { z } from "zod";
+
+export const DiscountSchema = z.object({
+ id: z.string(),
+ couponId: z.string().nullable(),
+ couponTestId: z.string().nullable(),
+ amount: z.number(),
+ type: z.nativeEnum(CommissionType),
+ duration: z.number().nullable(),
+ interval: z.nativeEnum(CommissionInterval).nullable(),
+});
diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts
index 4a60f51568c..69d48b36be5 100644
--- a/apps/web/lib/zod/schemas/programs.ts
+++ b/apps/web/lib/zod/schemas/programs.ts
@@ -6,6 +6,7 @@ import {
ProgramType,
} from "@dub/prisma/client";
import { z } from "zod";
+import { DiscountSchema } from "./discount";
import { LinkSchema } from "./links";
import { parseDateSchema } from "./utils";
@@ -19,12 +20,13 @@ export const ProgramSchema = z.object({
url: z.string().nullable(),
type: z.nativeEnum(ProgramType),
cookieLength: z.number(),
+ // Commission details
commissionAmount: z.number(),
commissionType: z.nativeEnum(CommissionType),
- recurringCommission: z.boolean(),
- recurringDuration: z.number().nullable(),
- recurringInterval: z.nativeEnum(CommissionInterval).nullable(),
- isLifetimeRecurring: z.boolean().nullable(),
+ commissionDuration: z.number().nullable(),
+ commissionInterval: z.nativeEnum(CommissionInterval).nullable(),
+ // Discounts (for dual-sided incentives)
+ discounts: z.array(DiscountSchema).nullish(),
wordmark: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
@@ -34,10 +36,8 @@ export const createProgramSchema = z.object({
name: z.string(),
commissionType: z.nativeEnum(CommissionType),
commissionAmount: z.number(),
- recurringCommission: z.boolean(),
- recurringInterval: z.nativeEnum(CommissionInterval).nullable(),
- recurringDuration: z.number().nullable(),
- isLifetimeRecurring: z.boolean().nullable(),
+ commissionDuration: z.number().nullable(),
+ commissionInterval: z.nativeEnum(CommissionInterval).nullable(),
cookieLength: z.number().min(1).max(180),
domain: z.string().nullable(),
url: z.string().nullable(),
@@ -59,6 +59,7 @@ export const ProgramEnrollmentSchema = z.object({
sales: true,
saleAmount: true,
}).nullable(),
+ discount: DiscountSchema.nullish(),
commissionAmount: z.number().nullable(),
createdAt: z.date(),
});
diff --git a/apps/web/tests/customers/index.test.ts b/apps/web/tests/customers/index.test.ts
index daa06629e9e..5b8464b2610 100644
--- a/apps/web/tests/customers/index.test.ts
+++ b/apps/web/tests/customers/index.test.ts
@@ -20,6 +20,8 @@ const expectedCustomer = {
avatar: customerRecord.avatar,
country: null,
partner: null,
+ link: null,
+ discount: null,
createdAt: expect.any(String),
};
diff --git a/apps/web/ui/auth/register/signup-email.tsx b/apps/web/ui/auth/register/signup-email.tsx
index d7ad9997fe5..cb1c4c4fa72 100644
--- a/apps/web/ui/auth/register/signup-email.tsx
+++ b/apps/web/ui/auth/register/signup-email.tsx
@@ -4,7 +4,6 @@ import { sendOtpAction } from "@/lib/actions/send-otp";
import z from "@/lib/zod";
import { signUpSchema } from "@/lib/zod/schemas/auth";
import { Button, Input } from "@dub/ui";
-import { zodResolver } from "@hookform/resolvers/zod";
import { useAction } from "next-safe-action/hooks";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -20,9 +19,7 @@ export const SignUpEmail = () => {
handleSubmit,
formState: { errors },
getValues,
- } = useForm({
- resolver: zodResolver(signUpSchema),
- });
+ } = useForm();
const { executeAsync, isExecuting } = useAction(sendOtpAction, {
onSuccess: () => {
diff --git a/apps/web/ui/partners/program-commission-description.tsx b/apps/web/ui/partners/program-commission-description.tsx
index 4d59fe0be00..c4cb6e39af9 100644
--- a/apps/web/ui/partners/program-commission-description.tsx
+++ b/apps/web/ui/partners/program-commission-description.tsx
@@ -1,60 +1,76 @@
-import { ProgramProps } from "@/lib/types";
-import { cn, currencyFormatter, pluralize } from "@dub/utils";
+import { DiscountProps, ProgramProps } from "@/lib/types";
+import { cn, currencyFormatter, INFINITY_NUMBER, pluralize } from "@dub/utils";
export function ProgramCommissionDescription({
program,
+ discount,
amountClassName,
periodClassName,
}: {
program: Pick<
ProgramProps,
- | "commissionType"
| "commissionAmount"
- | "recurringCommission"
- | "recurringDuration"
- | "recurringInterval"
- | "isLifetimeRecurring"
+ | "commissionType"
+ | "commissionDuration"
+ | "commissionInterval"
>;
+ discount?: DiscountProps | null;
amountClassName?: string;
periodClassName?: string;
}) {
+ const constructAmount = ({ amount, type }) => {
+ return type === "percentage"
+ ? `${amount}%`
+ : currencyFormatter(amount / 100, {
+ maximumFractionDigits: 2,
+ });
+ };
return (
<>
Earn{" "}
- {program.commissionType === "percentage"
- ? program.commissionAmount + "%"
- : currencyFormatter(program.commissionAmount / 100, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}{" "}
+ {constructAmount({
+ amount: program.commissionAmount,
+ type: program.commissionType,
+ })}{" "}
for each sale
- {program.isLifetimeRecurring ? (
+ {program.commissionDuration === INFINITY_NUMBER ? (
{" "}
for the customer's lifetime.
- ) : program.recurringCommission &&
- program.recurringDuration &&
- program.recurringDuration > 0 ? (
+ ) : program.commissionDuration && program.commissionDuration > 1 ? (
<>
, and again{" "}
- every {program.recurringInterval || "cycle"} for{" "}
- {program.recurringDuration
- ? `${program.recurringDuration} ${pluralize(program.recurringInterval || "cycle", program.recurringDuration)}.`
+ every {program.commissionInterval || "cycle"} for{" "}
+ {program.commissionDuration
+ ? `${program.commissionDuration} ${pluralize(program.commissionInterval || "cycle", program.commissionDuration)}.`
: null}
>
) : (
"."
- )}{" "}
- Referred users get{" "}
-
- 20% off for 3 months
-
- .
+ )}
+ {discount ? (
+ <>
+ {" "}
+ Referred users get{" "}
+
+ {constructAmount({
+ amount: discount.amount,
+ type: discount.type,
+ })}
+ {" "}
+ off for{" "}
+
+ {discount.duration
+ ? `${discount.duration} ${pluralize(discount.interval || "cycle", discount.duration)}.`
+ : "their first purchase."}
+
+ >
+ ) : null}
>
);
}
diff --git a/apps/web/ui/partners/program-invite-card.tsx b/apps/web/ui/partners/program-invite-card.tsx
index 3063bdbd7b5..8c7b42d5135 100644
--- a/apps/web/ui/partners/program-invite-card.tsx
+++ b/apps/web/ui/partners/program-invite-card.tsx
@@ -52,6 +52,7 @@ export function ProgramInviteCard({
diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma
new file mode 100644
index 00000000000..30a457c01df
--- /dev/null
+++ b/packages/prisma/schema/discount.prisma
@@ -0,0 +1,21 @@
+model Discount {
+ id String @id @default(cuid())
+
+ amount Int?
+ type CommissionType?
+ duration Int?
+ interval CommissionInterval?
+ couponId String?
+ couponTestId String?
+
+ workspaceId String
+ programId String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ program Program @relation(fields: [programId], references: [id])
+ programEnrollments ProgramEnrollment[]
+
+ @@index([programId])
+}
diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma
index e945295669b..44671af006a 100644
--- a/packages/prisma/schema/program.prisma
+++ b/packages/prisma/schema/program.prisma
@@ -36,8 +36,17 @@ model Program {
url String?
type ProgramType @default(affiliate)
cookieLength Int @default(90)
+
+ // Commission details
commissionAmount Int @default(0)
commissionType CommissionType @default(percentage)
+ commissionDuration Int?
+ commissionInterval CommissionInterval?
+
+ // Discounts (for dual-sided incentives)
+ discounts Discount[]
+
+ // TODO REMOVE
recurringCommission Boolean @default(false)
recurringInterval CommissionInterval?
recurringDuration Int?
@@ -67,10 +76,10 @@ model ProgramEnrollment {
id String @id @default(cuid())
partnerId String
programId String
- applicationId String? @unique
linkId String? @unique
- couponId String? // Stripe coupon ID
- commissionAmount Int? // override the program's commission amount
+ commissionAmount Int? // custom commission amount for this partner
+ discountId String? // custom discount for this partner
+ applicationId String? @unique
status ProgramEnrollmentStatus @default(pending)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -78,10 +87,12 @@ model ProgramEnrollment {
partner Partner @relation(fields: [partnerId], references: [id])
program Program @relation(fields: [programId], references: [id])
link Link? @relation(fields: [linkId], references: [id])
+ discount Discount? @relation(fields: [discountId], references: [id])
application ProgramApplication? @relation(fields: [applicationId], references: [id])
-
+
@@unique([partnerId, programId])
@@index([programId])
+ @@index([discountId])
}
model ProgramApplication {