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 {