Skip to content

Commit

Permalink
Support different partner types
Browse files Browse the repository at this point in the history
  • Loading branch information
steven-tey committed Jan 5, 2025
1 parent e933f60 commit 81ad48c
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 163 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { invitePartnerAction } from "@/lib/actions/partners/invite-partner";
import { mutatePrefix } from "@/lib/swr/mutate-prefix";
import useProgram from "@/lib/swr/use-program";
import useWorkspace from "@/lib/swr/use-workspace";
import { PartnerLinkSelector } from "@/ui/partners/partner-link-selector";
Expand All @@ -7,9 +8,11 @@ import {
AnimatedSizeContainer,
BlurImage,
Button,
CircleCheckFill,
Sheet,
useMediaQuery,
} from "@dub/ui";
import { cn } from "@dub/utils";
import { useAction } from "next-safe-action/hooks";
import { Dispatch, SetStateAction, useState } from "react";
import { useForm } from "react-hook-form";
Expand All @@ -20,14 +23,32 @@ interface InvitePartnerSheetProps {
}

interface InvitePartnerFormData {
email: string;
name?: string;
email?: string;
linkId: string;
}

const inviteTypes = [
{
id: "affiliate",
label: "Affiliate Partner",
description:
"Partner will receive an email invitation to join your program",
},
{
id: "referral",
label: "Referral Partner",
description: "Partner will not receive an email invitation.",
},
];

function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
const { program } = useProgram();
const { id: workspaceId, slug } = useWorkspace();
const { isMobile } = useMediaQuery();
const [selectedInviteType, setSelectedInviteType] = useState<
"affiliate" | "referral"
>("affiliate");

const {
register,
Expand All @@ -37,21 +58,15 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
setError,
clearErrors,
formState: { errors },
} = useForm<InvitePartnerFormData>({
defaultValues: {
email: "",
linkId: "",
},
});
} = useForm<InvitePartnerFormData>();

const selectedLinkId = watch("linkId");

const { executeAsync, isExecuting } = useAction(invitePartnerAction, {
onSuccess: async () => {
toast.success("Successfully invited partner!");
setIsOpen(false);

// TODO: refresh the invites list
program && mutatePrefix(`/api/programs/${program.id}/partners`);
},
onError({ error }) {
toast.error(error.serverError);
Expand Down Expand Up @@ -92,26 +107,19 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
return result.id;
};

const onSubmit = async (data: InvitePartnerFormData) => {
let { linkId } = data;

try {
if (!linkId)
setError("linkId", { message: "Please select a referral link" });

await executeAsync({
workspaceId: workspaceId!,
programId: program?.id!,
email: data.email,
linkId,
});
} catch (error) {
toast.error(error.message);
}
};

return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<form
onSubmit={handleSubmit(async (data) => {
await executeAsync({
workspaceId: workspaceId!,
programId: program?.id!,
name: data.name || undefined,
email: data.email || undefined,
linkId: data.linkId,
});
})}
className="flex h-full flex-col"
>
<div>
<div className="flex items-start justify-between border-b border-neutral-200 p-6">
<Sheet.Title className="text-xl font-semibold">
Expand All @@ -126,7 +134,68 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
</Sheet.Close>
</div>
<div className="p-6">
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{inviteTypes.map((inviteType) => {
const isSelected = inviteType.id === selectedInviteType;

return (
<label
key={inviteType.label}
className={cn(
"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50",
"transition-all duration-150",
isSelected &&
"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black",
)}
>
<input
type="radio"
value={inviteType.label}
className="hidden"
checked={isSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedInviteType(
inviteType.id as "affiliate" | "referral",
);
}
}}
/>
<div className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">{inviteType.label}</span>
<span className="text-xs text-gray-500">
{inviteType.description}
</span>
</div>
<CircleCheckFill
className={cn(
"-mr-px -mt-px flex size-4 shrink-0 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150",
isSelected && "scale-100 opacity-100",
)}
/>
</label>
);
})}
</div>
<div className="mt-4 grid gap-4">
{selectedInviteType === "referral" && (
<div>
<label htmlFor="name" className="flex items-center space-x-2">
<h2 className="text-sm font-medium text-gray-900">Name</h2>
</label>
<div className="relative mt-2 rounded-md shadow-sm">
<input
{...register("name")}
className="block w-full rounded-md border-gray-300 text-gray-900 placeholder-gray-400 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
placeholder="John Doe"
type="text"
autoComplete="off"
autoFocus={!isMobile}
/>
</div>
</div>
)}

<div>
<label htmlFor="email" className="flex items-center space-x-2">
<h2 className="text-sm font-medium text-gray-900">Email</h2>
Expand All @@ -136,10 +205,9 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
{...register("email")}
className="block w-full rounded-md border-gray-300 text-gray-900 placeholder-gray-400 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
placeholder="[email protected]"
required
type="email"
autoComplete="off"
autoFocus={!isMobile}
autoFocus={!isMobile && selectedInviteType !== "affiliate"}
/>
</div>
</div>
Expand Down Expand Up @@ -191,7 +259,9 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
</div>
</AnimatedSizeContainer>
</div>
</div>

{selectedInviteType === "affiliate" && (
<div className="mt-8">
<h2 className="text-sm font-medium text-gray-900">Preview</h2>
<div className="mt-2 overflow-hidden rounded-md border border-neutral-200">
Expand Down Expand Up @@ -233,7 +303,7 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex grow flex-col justify-end">
Expand Down
138 changes: 128 additions & 10 deletions apps/web/lib/actions/partners/invite-partner.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
"use server";

import { createId } from "@/lib/api/utils";
import { updateConfig } from "@/lib/edge-config";
import { recordLink } from "@/lib/tinybird";
import { prisma } from "@dub/prisma";
import { waitUntil } from "@vercel/functions";
import { sendEmail } from "emails";
import PartnerInvite from "emails/partner-invite";
import { z } from "zod";
import { getLinkOrThrow } from "../../api/links/get-link-or-throw";
import { invitePartner } from "../../api/partners/invite-partner";
import { getProgramOrThrow } from "../../api/programs/get-program-or-throw";
import { authActionClient } from "../safe-action";

const invitePartnerSchema = z.object({
workspaceId: z.string(),
programId: z.string(),
email: z.string().trim().email().min(1).max(100),
name: z.string().trim().min(1).max(100).optional(),
email: z.string().trim().email().min(1).max(100).optional(),
linkId: z.string(),
});

export const invitePartnerAction = authActionClient
.schema(invitePartnerSchema)
.action(async ({ parsedInput, ctx }) => {
const { workspace } = ctx;
const { email, linkId, programId } = parsedInput;
const { name, email, linkId, programId } = parsedInput;

if (!email && !name) {
throw new Error("Either name or email must be provided");
}

const [program, link] = await Promise.all([
getProgramOrThrow({
workspaceId: workspace.id,
programId,
}),

getLinkOrThrow({
workspaceId: workspace.id,
linkId,
Expand All @@ -35,10 +45,118 @@ export const invitePartnerAction = authActionClient
throw new Error("Link is already associated with another partner.");
}

return await invitePartner({
email,
program,
link,
workspace,
});
if (email) {
const [programEnrollment, programInvite] = await Promise.all([
prisma.programEnrollment.findFirst({
where: {
programId: program.id,
partner: {
email,
},
},
}),
prisma.programInvite.findUnique({
where: {
email_programId: {
email,
programId: program.id,
},
},
}),
]);

if (programEnrollment) {
throw new Error(`Partner ${email} already enrolled in this program.`);
}

if (programInvite) {
throw new Error(`Partner ${email} already invited to this program.`);
}

waitUntil(
Promise.all([
prisma.programInvite.create({
data: {
id: createId({ prefix: "pgi_" }),
email,
linkId: link.id,
programId: program.id,
},
}),
// TODO: Remove this once we open up partners.dub.co to everyone
updateConfig({
key: "partnersPortal",
value: email,
}).then(() =>
sendEmail({
subject: `${program.name} invited you to join Dub Partners`,
email,
react: PartnerInvite({
email,
appName: `${process.env.NEXT_PUBLIC_APP_NAME}`,
program: {
name: program.name,
logo: program.logo,
},
}),
}),
),
updateLink({ link, program, workspace }),
]),
);
} else if (name) {
const partner = await prisma.partner.create({
data: {
id: createId({ prefix: "pn_" }),
name,
email: "[email protected]", // TODO: fix this
programs: {
create: {
programId: program.id,
linkId: link.id,
commissionAmount: 0,
},
},
},
});
console.log("partner created", partner);
waitUntil(updateLink({ link, program, workspace }));
}
return { done: true };
});

const updateLink = async ({ link, program, workspace }) => {
const tags = await prisma.tag.findMany({
where: {
links: {
some: {
linkId: link.id,
},
},
},
});

await Promise.all([
// update link to have programId
prisma.link.update({
where: {
id: link.id,
},
data: {
programId: program.id,
},
}),
// record link update in tinybird
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) || [],
program_id: program.id,
workspace_id: workspace.id,
deleted: false,
}),
]);
};
Loading

0 comments on commit 81ad48c

Please sign in to comment.