diff --git a/apps/web/app/api/mock/rewardful/campaigns/route.ts b/apps/web/app/api/mock/rewardful/campaigns/route.ts
index 0c8dfd9543..74df2b5f4c 100644
--- a/apps/web/app/api/mock/rewardful/campaigns/route.ts
+++ b/apps/web/app/api/mock/rewardful/campaigns/route.ts
@@ -62,7 +62,7 @@ export async function GET() {
visitors: 150,
leads: 39,
conversions: 7,
- affiliates: 12,
+ affiliates: 45,
},
];
diff --git a/apps/web/app/api/programs/[programId]/rewardful/campaigns/route.ts b/apps/web/app/api/programs/[programId]/rewardful/campaigns/route.ts
deleted file mode 100644
index edd64aa533..0000000000
--- a/apps/web/app/api/programs/[programId]/rewardful/campaigns/route.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
-import { withWorkspace } from "@/lib/auth";
-import { RewardfulApi } from "@/lib/rewardful/api";
-import { rewardfulImporter } from "@/lib/rewardful/importer";
-import { NextResponse } from "next/server";
-
-// GET /api/programs/[programId]/rewardful/campaigns - list rewardful campaigns
-export const GET = withWorkspace(async ({ workspace, params }) => {
- const { programId } = params;
-
- await getProgramOrThrow({
- workspaceId: workspace.id,
- programId,
- });
-
- const { token } = await rewardfulImporter.getCredentials(programId);
- const rewardfulApi = new RewardfulApi({ token });
- const campaigns = await rewardfulApi.listCampaigns();
-
- return NextResponse.json(campaigns);
-});
diff --git a/apps/web/app/api/programs/rewardful/campaigns/route.ts b/apps/web/app/api/programs/rewardful/campaigns/route.ts
new file mode 100644
index 0000000000..0bebad20d1
--- /dev/null
+++ b/apps/web/app/api/programs/rewardful/campaigns/route.ts
@@ -0,0 +1,20 @@
+import { DubApiError } from "@/lib/api/errors";
+import { withWorkspace } from "@/lib/auth";
+import { RewardfulApi } from "@/lib/rewardful/api";
+import { rewardfulImporter } from "@/lib/rewardful/importer";
+import { NextResponse } from "next/server";
+
+// GET /api/programs/rewardful/campaigns - list rewardful campaigns
+export const GET = withWorkspace(async ({ workspace }) => {
+ if (!workspace.partnersEnabled) {
+ throw new DubApiError({
+ code: "forbidden",
+ message: "Partners are not enabled for this workspace",
+ });
+ }
+
+ const { token } = await rewardfulImporter.getCredentials(workspace.id);
+ const rewardfulApi = new RewardfulApi({ token });
+
+ return NextResponse.json(await rewardfulApi.listCampaigns());
+});
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/layout.tsx
new file mode 100644
index 0000000000..efb79ca2a5
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/layout.tsx
@@ -0,0 +1,20 @@
+import RootProviders from "app/providers";
+import { FormWrapper } from "./new/form-wrapper";
+import { Header } from "./new/header";
+import { Steps } from "./new/steps";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/connect/form.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/connect/form.tsx
new file mode 100644
index 0000000000..f600118775
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/connect/form.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import useWorkspace from "@/lib/swr/use-workspace";
+import { Shopify } from "@/ui/layout/sidebar/conversions/icons/shopify";
+import { Stripe } from "@/ui/layout/sidebar/conversions/icons/stripe";
+import { Button } from "@dub/ui";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+const GUIDES = [
+ {
+ name: "Connecting to Stripe",
+ icon: Stripe,
+ href: "https://d.to/stripe",
+ },
+ {
+ name: "Connecting to Shopify",
+ icon: Shopify,
+ href: "https://d.to/shopify",
+ },
+] as const;
+
+export function Form() {
+ const router = useRouter();
+ const [isPending, setIsPending] = useState(false);
+ const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
+
+ const onClick = async () => {
+ if (!workspaceId) return;
+ setIsPending(true);
+ router.push(`/${workspaceSlug}/programs/new/overview`);
+ };
+
+ return (
+
+
+
+ Ensuring your program is connect is simple, select the best guide that
+ suits your connection setup or something else.
+
+
+
+ {GUIDES.map(({ name, icon: Icon, href }) => (
+
+
+
+
+ {name}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/connect/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/connect/page.tsx
new file mode 100644
index 0000000000..30db0f3836
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/connect/page.tsx
@@ -0,0 +1,10 @@
+import { StepPage } from "../step-page";
+import { Form } from "./form";
+
+export default async function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/form-wrapper.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/form-wrapper.tsx
new file mode 100644
index 0000000000..20efb65b7e
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/form-wrapper.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useWorkspaceStore } from "@/lib/swr/use-workspace-store";
+import { ProgramData } from "@/lib/zod/schemas/program-onboarding";
+import { FormProvider, useForm } from "react-hook-form";
+
+export function FormWrapper({ children }: { children: React.ReactNode }) {
+ const [programOnboarding] =
+ useWorkspaceStore("programOnboarding");
+
+ const methods = useForm({
+ defaultValues: {
+ ...programOnboarding,
+ linkType: programOnboarding?.linkType ?? "short",
+ programType: programOnboarding?.programType ?? "new",
+ type: programOnboarding?.type ?? "flat",
+ amount: programOnboarding?.amount ?? null,
+ partners: programOnboarding?.partners?.length
+ ? programOnboarding.partners
+ : [{ email: "", key: "" }],
+ },
+ });
+
+ return {children} ;
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/form.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/form.tsx
new file mode 100644
index 0000000000..c562896e2d
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/form.tsx
@@ -0,0 +1,244 @@
+"use client";
+
+import { onboardProgramAction } from "@/lib/actions/partners/onboard-program";
+import useDomains from "@/lib/swr/use-domains";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { useWorkspaceStore } from "@/lib/swr/use-workspace-store";
+import { ProgramData } from "@/lib/zod/schemas/program-onboarding";
+import {
+ Badge,
+ Button,
+ CircleCheckFill,
+ FileUpload,
+ Input,
+ useMediaQuery,
+} from "@dub/ui";
+import { cn } from "@dub/utils";
+import { Plus } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useRouter } from "next/navigation";
+import { Controller, useFormContext } from "react-hook-form";
+import { toast } from "sonner";
+
+export const LINK_TYPES = [
+ {
+ value: "short",
+ label: "Short link",
+ preview: "refer.dub.co/steven",
+ disabled: false,
+ },
+ {
+ value: "query",
+ label: "Query parameter",
+ preview: "dub.co/?via=steven",
+ disabled: false,
+ },
+ {
+ value: "dynamic",
+ label: "Dynamic path",
+ preview: "dub.co/refer/steven",
+ disabled: true,
+ badge: "Coming soon",
+ },
+];
+
+export function Form() {
+ const router = useRouter();
+ const { isMobile } = useMediaQuery();
+ const { activeWorkspaceDomains, loading } = useDomains();
+ const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
+ const [_, __, { mutateWorkspace }] = useWorkspaceStore("programOnboarding");
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ control,
+ formState: { isSubmitting },
+ } = useFormContext();
+
+ const { executeAsync, isPending } = useAction(onboardProgramAction, {
+ onSuccess: () => {
+ mutateWorkspace();
+ router.push(`/${workspaceSlug}/programs/new/rewards`);
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
+
+ const onSubmit = async (data: ProgramData) => {
+ if (!workspaceId) return;
+
+ await executeAsync({
+ ...data,
+ workspaceId,
+ step: "get-started",
+ });
+ };
+
+ const [name, url, domain, logo] = watch(["name", "url", "domain", "logo"]);
+
+ const buttonDisabled =
+ isSubmitting || isPending || !name || !url || !domain || !logo;
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/header.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/header.tsx
new file mode 100644
index 0000000000..15c48f3274
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/header.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { onboardProgramAction } from "@/lib/actions/partners/onboard-program";
+import usePrograms from "@/lib/swr/use-programs";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { Button, Wordmark } from "@dub/ui";
+import { useAction } from "next-safe-action/hooks";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useFormContext } from "react-hook-form";
+import { toast } from "sonner";
+
+export function Header() {
+ const router = useRouter();
+ const { getValues } = useFormContext();
+ const { programs, loading: programsLoading } = usePrograms();
+ const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
+ const { partnersEnabled, loading: workspaceLoading } = useWorkspace();
+
+ const { executeAsync, isPending } = useAction(onboardProgramAction, {
+ onSuccess: () => {
+ router.push(`/${workspaceSlug}`);
+ },
+ onError: ({ error }) => {
+ console.log(error);
+ toast.error(error.serverError);
+ },
+ });
+
+ if (programsLoading || workspaceLoading) {
+ return (
+
+ );
+ }
+
+ if ((programs && programs.length > 0) || !partnersEnabled) {
+ router.push(`/${workspaceSlug}`);
+ }
+
+ const saveAndExit = async () => {
+ if (!workspaceId) return;
+
+ const data = getValues();
+
+ data.partners =
+ data?.partners?.filter(
+ (partner) => partner.email !== "" && partner.key !== "",
+ ) ?? null;
+
+ await executeAsync({
+ ...data,
+ workspaceId,
+ step: "save-and-exit",
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/overview/form.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/overview/form.tsx
new file mode 100644
index 0000000000..b7c647c881
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/overview/form.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { onboardProgramAction } from "@/lib/actions/partners/onboard-program";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { ProgramData } from "@/lib/zod/schemas/program-onboarding";
+import { ProgramRewardDescription } from "@/ui/partners/program-reward-description";
+import { CommissionType, EventType } from "@dub/prisma/client";
+import { Button } from "@dub/ui";
+import { Pencil } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useMemo } from "react";
+import { useFormContext } from "react-hook-form";
+import { toast } from "sonner";
+import { LINK_TYPES } from "../form";
+
+export function Form() {
+ const router = useRouter();
+ const { getValues } = useFormContext();
+ const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
+
+ const data = getValues();
+
+ const { executeAsync, isPending } = useAction(onboardProgramAction, {
+ onSuccess: ({ data }) => {
+ if (data?.id) {
+ router.push(`/${workspaceSlug}/programs/${data.id}?onboarded-program`);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
+
+ const onClick = async () => {
+ if (!workspaceId) return;
+
+ await executeAsync({
+ ...data,
+ workspaceId,
+ step: "create-program",
+ });
+ };
+
+ const isValid = useMemo(() => {
+ const {
+ name,
+ url,
+ domain,
+ logo,
+ programType,
+ rewardful,
+ type,
+ amount,
+ maxDuration,
+ } = data;
+
+ if (!name || !url || !domain || !logo) {
+ return false;
+ }
+
+ if (programType === "new" && (!amount || !type || !maxDuration)) {
+ return false;
+ }
+
+ if (programType === "import" && (!rewardful || !rewardful.id)) {
+ return false;
+ }
+
+ return true;
+ }, [data]);
+
+ const reward = data.rewardful
+ ? {
+ type:
+ data.rewardful.reward_type === "amount"
+ ? ("flat" as const)
+ : ("percentage" as const),
+ amount:
+ data.rewardful.reward_type === "amount"
+ ? data.rewardful.commission_amount_cents ?? 0
+ : data.rewardful.commission_percent ?? 0,
+ maxDuration: data.rewardful.max_commission_period_months,
+ event: "sale" as EventType,
+ }
+ : {
+ type: (data.type ?? "flat") as CommissionType,
+ amount: data.amount ?? 0,
+ maxDuration: data.maxDuration ?? 0,
+ event: "sale" as EventType,
+ };
+
+ const SECTIONS = [
+ {
+ title: "Reward",
+ content: reward ? : null,
+ href: `/${workspaceSlug}/programs/new/rewards`,
+ },
+ {
+ title: "Referral link structure",
+ content: LINK_TYPES.find((linkType) => linkType.value === data.linkType)
+ ?.preview,
+ href: `/${workspaceSlug}/programs/new`,
+ },
+ {
+ title: "Destination URL",
+ content: data.url,
+ href: `/${workspaceSlug}/programs/new`,
+ },
+ ] as const;
+
+ return (
+
+ {SECTIONS.map(({ title, content, href }) => (
+
+
+
+ {title}
+
+
+
}
+ variant="outline"
+ className="h-8 w-8 shrink-0 p-0"
+ />
+
+
+
+ {content}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/overview/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/overview/page.tsx
new file mode 100644
index 0000000000..1ca95bb56b
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/overview/page.tsx
@@ -0,0 +1,10 @@
+import { StepPage } from "../step-page";
+import { Form } from "./form";
+
+export default async function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/page.tsx
new file mode 100644
index 0000000000..7d78495f9b
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/page.tsx
@@ -0,0 +1,10 @@
+import { Form } from "./form";
+import { StepPage } from "./step-page";
+
+export default async function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/partners/form.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/partners/form.tsx
new file mode 100644
index 0000000000..c83e0df9a7
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/partners/form.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { onboardProgramAction } from "@/lib/actions/partners/onboard-program";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { useWorkspaceStore } from "@/lib/swr/use-workspace-store";
+import { ProgramData } from "@/lib/zod/schemas/program-onboarding";
+import { AlertCircleFill } from "@/ui/shared/icons";
+import { Button, Input } from "@dub/ui";
+import { cn } from "@dub/utils";
+import { Plus, Trash2 } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+import { useFieldArray, useFormContext } from "react-hook-form";
+import { toast } from "sonner";
+import { useDebounce } from "use-debounce";
+
+export function Form() {
+ const router = useRouter();
+ const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
+ const [keyErrors, setKeyErrors] = useState<{ [key: number]: string }>({});
+ const [_, __, { mutateWorkspace }] = useWorkspaceStore("programOnboarding");
+
+ const {
+ register,
+ control,
+ handleSubmit,
+ setValue,
+ watch,
+ formState: { isSubmitting },
+ } = useFormContext();
+
+ const { fields, append, remove } = useFieldArray({
+ name: "partners",
+ control,
+ });
+
+ const [rewardful, domain, partners] = watch([
+ "rewardful",
+ "domain",
+ "partners",
+ ]);
+
+ const [debouncedPartners] = useDebounce(partners, 500);
+
+ const generateKeyFromEmail = useCallback((email: string) => {
+ if (!email) return "";
+ const prefix = email.split("@")[0];
+ const randomNum = Math.floor(1000 + Math.random() * 9000);
+ return `${prefix}${randomNum}`;
+ }, []);
+
+ const handleKeyFocus = (index: number) => {
+ const email = watch(`partners.${index}.email`);
+ const currentKey = watch(`partners.${index}.key`);
+ if (email && !currentKey) {
+ setValue(`partners.${index}.key`, generateKeyFromEmail(email));
+ }
+ };
+
+ const runKeyChecks = async (index: number, value: string) => {
+ if (!value || !domain || !workspaceId) return;
+
+ try {
+ const res = await fetch(
+ `/api/links/exists?domain=${domain}&key=${value}&workspaceId=${workspaceId}`,
+ );
+
+ const { error } = await res.json();
+
+ if (error) {
+ setKeyErrors((prev) => ({
+ ...prev,
+ [index]: error.message,
+ }));
+ } else {
+ setKeyErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[index];
+ return newErrors;
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ const handleKeyChange = (index: number, key: string) => {
+ setKeyErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[index];
+ return newErrors;
+ });
+ };
+
+ useEffect(() => {
+ if (!debouncedPartners) return;
+
+ debouncedPartners.forEach((partner, index) => {
+ if (partner.key) {
+ runKeyChecks(index, partner.key);
+ }
+ });
+ }, [debouncedPartners, domain, workspaceId]);
+
+ const { executeAsync, isPending } = useAction(onboardProgramAction, {
+ onSuccess: () => {
+ mutateWorkspace();
+ router.push(`/${workspaceSlug}/programs/new/connect`);
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
+
+ const onSubmit = async (data: ProgramData) => {
+ if (!workspaceId) return;
+
+ data.partners =
+ data?.partners?.filter(
+ (partner) => partner.email !== "" && partner.key !== "",
+ ) ?? null;
+
+ await executeAsync({
+ ...data,
+ workspaceId,
+ step: "invite-partners",
+ });
+ };
+
+ return (
+
+ {rewardful?.affiliates && (
+
+
+ Invite new partners in addition to those being imported.
+
+
+
+
+
+
+
+
+ Affiliates importing
+
+
+
+ {rewardful?.affiliates}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/partners/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/partners/page.tsx
new file mode 100644
index 0000000000..4626b42a68
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/partners/page.tsx
@@ -0,0 +1,10 @@
+import { StepPage } from "../step-page";
+import { Form } from "./form";
+
+export default async function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/rewards/form.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/rewards/form.tsx
new file mode 100644
index 0000000000..d4da93bab6
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/rewards/form.tsx
@@ -0,0 +1,495 @@
+"use client";
+
+import { onboardProgramAction } from "@/lib/actions/partners/onboard-program";
+import { setRewardfulTokenAction } from "@/lib/actions/partners/set-rewardful-token";
+import { handleMoneyInputChange, handleMoneyKeyDown } from "@/lib/form-utils";
+import { RewardfulCampaign } from "@/lib/rewardful/types";
+import { useRewardfulCampaigns } from "@/lib/swr/use-rewardful-campaigns";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { useWorkspaceStore } from "@/lib/swr/use-workspace-store";
+import { ProgramData } from "@/lib/zod/schemas/program-onboarding";
+import {
+ COMMISSION_TYPES,
+ RECURRING_MAX_DURATIONS,
+} from "@/lib/zod/schemas/rewards";
+import { Button, CircleCheckFill, Input, InputSelect } from "@dub/ui";
+import { capitalize, cn } from "@dub/utils";
+import { ChevronDown } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+import {
+ useFormContext,
+ UseFormRegister,
+ UseFormSetValue,
+ UseFormWatch,
+} from "react-hook-form";
+import { toast } from "sonner";
+
+type FormProps = {
+ register: UseFormRegister;
+ watch: UseFormWatch;
+ setValue: UseFormSetValue;
+};
+
+export const PROGRAM_TYPES = [
+ {
+ value: "new",
+ label: "New program",
+ description: "Start a brand-new program",
+ },
+ {
+ value: "import",
+ label: "Import program",
+ description: "Migrate an existing program",
+ },
+] as const;
+
+const IMPORT_SOURCES = [
+ {
+ id: "rewardful",
+ value: "Rewardful",
+ image: "https://assets.dub.co/misc/icons/rewardful.svg",
+ },
+];
+
+export function Form() {
+ const router = useRouter();
+ const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
+ const [_, __, { mutateWorkspace }] = useWorkspaceStore("programOnboarding");
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { isSubmitting },
+ } = useFormContext();
+
+ const [programType, rewardful, amount] = watch([
+ "programType",
+ "rewardful",
+ "amount",
+ ]);
+
+ const { executeAsync, isPending } = useAction(onboardProgramAction, {
+ onSuccess: () => {
+ mutateWorkspace();
+ router.push(`/${workspaceSlug}/programs/new/partners`);
+ },
+ onError: ({ error }) => {
+ console.log(error);
+ toast.error(error.serverError);
+ },
+ });
+
+ const onSubmit = async (data: ProgramData) => {
+ if (!workspaceId) return;
+
+ const programData = {
+ ...data,
+ ...(programType === "new" && {
+ rewardful: null,
+ }),
+ ...(programType === "import" && {
+ type: null,
+ amount: null,
+ maxDuration: null,
+ }),
+ };
+
+ await executeAsync({
+ ...programData,
+ workspaceId,
+ step: "configure-reward",
+ });
+ };
+
+ const buttonDisabled =
+ isSubmitting ||
+ isPending ||
+ (programType === "new" && !amount) ||
+ (programType === "import" && (!rewardful || !rewardful.id));
+
+ const hideContinueButton =
+ programType === "import" && (!rewardful || !rewardful.id);
+
+ return (
+
+ );
+}
+
+const NewProgramForm = ({ register, watch, setValue }: FormProps) => {
+ const [isRecurring, setIsRecurring] = useState(false);
+ const [type, maxDuration] = watch(["type", "maxDuration"]);
+
+ useEffect(() => {
+ setIsRecurring(maxDuration !== 0);
+ }, [maxDuration]);
+
+ return (
+ <>
+
+
+
+ Commission structure
+
+
+ Set how the affiliate will get rewarded
+
+
+
+
+ {COMMISSION_TYPES.map(({ value, label, description }) => {
+ const isSelected = (value === "recurring") === isRecurring;
+
+ return (
+
+ {
+ if (value === "one-off") {
+ setIsRecurring(false);
+ setValue("maxDuration", 0, { shouldValidate: true });
+ }
+
+ if (value === "recurring") {
+ setIsRecurring(true);
+ setValue("maxDuration", 3, {
+ shouldValidate: true,
+ });
+ }
+ }}
+ />
+
+
+ {label}
+
+
+ {description}
+
+
+
+
+ );
+ })}
+
+
+ {isRecurring && (
+
+
+ Duration
+
+
+ {RECURRING_MAX_DURATIONS.filter((v) => v !== 0).map(
+ (duration) => (
+
+ {duration} {duration === 1 ? "month" : "months"}
+
+ ),
+ )}
+ Lifetime
+
+
+ )}
+
+
+
+
+
Payout
+
+ Set how much the affiliate will get rewarded
+
+
+
+
+
+ Payout model
+
+
+ Flat
+ Percentage
+
+
+
+
+
Amount
+
+
+ {type === "flat" && "$"}
+
+
+
+ {type === "flat" ? "USD" : "%"}
+
+
+
+
+ >
+ );
+};
+
+const ImportProgramForm = ({ register, watch, setValue }: FormProps) => {
+ const [token, setToken] = useState("");
+ const { id: workspaceId } = useWorkspace();
+ const [selectedSource, setSelectedSource] = useState(IMPORT_SOURCES[0]);
+
+ const {
+ executeAsync: setRewardfulToken,
+ isPending: isSettingRewardfulToken,
+ } = useAction(setRewardfulTokenAction, {
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
+
+ const rewardful = watch("rewardful");
+
+ const { campaigns, loading: isLoadingCampaigns } = useRewardfulCampaigns({
+ enabled: !!token || !!rewardful?.id,
+ });
+
+ const selectedCampaign = campaigns?.find(
+ (campaign) => campaign.id === rewardful?.id,
+ );
+
+ useEffect(() => {
+ if (selectedCampaign) {
+ setValue("rewardful", selectedCampaign);
+ }
+ }, [selectedCampaign, setValue]);
+
+ const formatCommission = useCallback((campaign: RewardfulCampaign) => {
+ return campaign.reward_type === "percent"
+ ? `${campaign.commission_percent}%`
+ : `$${(campaign.commission_amount_cents / 100).toFixed(2)}`;
+ }, []);
+
+ return (
+
+
+
+ Import source
+
+
+
+
+
+ See what data is migrated
+
+
+
+
+
+ Rewardful API secret
+
+
setToken(e.target.value)}
+ />
+
+ Find your Rewardful API secret on your{" "}
+
+ Company settings page
+
+
+
+
+
+
+ Campaign to import
+
+
+
+ Select a campaign
+ {campaigns?.map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
+
+
+
+ Want to migrate more than one campaign?
+
+
+
+ {selectedCampaign && (
+
+
+
Type
+
+ {capitalize(selectedCampaign.reward_type)}
+
+
+
+
Duration
+
+ {selectedCampaign.max_commission_period_months} months
+
+
+
+
Commission
+
+ {formatCommission(selectedCampaign)}
+
+
+
+
Affiliates
+
+ {selectedCampaign.affiliates}
+
+
+
+ )}
+
+ {token && !rewardful?.id && (
+
{
+ if (!workspaceId || !token) return;
+
+ await setRewardfulToken({
+ workspaceId,
+ token,
+ });
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/rewards/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/rewards/page.tsx
new file mode 100644
index 0000000000..1c3cc0db14
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/rewards/page.tsx
@@ -0,0 +1,10 @@
+import { StepPage } from "../step-page";
+import { Form } from "./form";
+
+export default async function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/step-page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/step-page.tsx
new file mode 100644
index 0000000000..4c4aa8b676
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/step-page.tsx
@@ -0,0 +1,26 @@
+import { cn } from "@dub/utils";
+import { PropsWithChildren, ReactNode } from "react";
+
+export function StepPage({
+ children,
+ title,
+ className,
+}: PropsWithChildren<{
+ title: ReactNode;
+ className?: string;
+}>) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/steps.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/steps.tsx
new file mode 100644
index 0000000000..55026876ac
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/(new)/new/steps.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useMediaQuery } from "@dub/ui";
+import { cn } from "@dub/utils";
+import { Check, Lock, Menu, X } from "lucide-react";
+import Link from "next/link";
+import { useParams, usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
+
+export function Steps() {
+ const pathname = usePathname();
+ const { isMobile } = useMediaQuery();
+ const [isOpen, setIsOpen] = useState(false);
+ const { slug } = useParams<{ slug: string }>();
+
+ useEffect(() => {
+ document.body.style.overflow = isOpen && isMobile ? "hidden" : "auto";
+ }, [isOpen, isMobile]);
+
+ const steps = [
+ {
+ step: 1,
+ label: "Getting started",
+ href: `/${slug}/programs/new`,
+ },
+ {
+ step: 2,
+ label: "Configure reward",
+ href: `/${slug}/programs/new/rewards`,
+ },
+ {
+ step: 3,
+ label: "Invite partners",
+ href: `/${slug}/programs/new/partners`,
+ },
+ {
+ step: 4,
+ label: "Connect Dub",
+ href: `/${slug}/programs/new/connect`,
+ },
+ {
+ step: 5,
+ label: "Overview",
+ href: `/${slug}/programs/new/overview`,
+ isLocked: true,
+ },
+ ];
+
+ const currentStep = steps.find((s) => s.href === pathname)?.step || 1;
+
+ return (
+ <>
+ setIsOpen(true)}
+ className="fixed left-4 top-[72px] z-20 rounded-md p-1 hover:bg-neutral-100 md:hidden"
+ >
+
+
+
+ {
+ if (e.target === e.currentTarget) {
+ e.stopPropagation();
+ setIsOpen(false);
+ }
+ }}
+ >
+
+
+
+
Program Setup
+ setIsOpen(false)}
+ className="rounded-md p-1 hover:bg-neutral-100"
+ >
+
+
+
+
+ {steps.map(({ step, label, href, isLocked }) => {
+ const current = pathname === href;
+ const completed = pathname !== href && step < currentStep;
+
+ return (
+
+
+ {isLocked ? (
+
+ ) : completed ? (
+
+ ) : (
+ step
+ )}
+
+
+ {label}
+
+
+ );
+ })}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/folders/[folderId]/members/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/folders/[folderId]/members/page-client.tsx
index cd833e403b..34ed03a239 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/folders/[folderId]/members/page-client.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/folders/[folderId]/members/page-client.tsx
@@ -203,7 +203,7 @@ const FolderUserRow = ({
"folders.users.write",
);
- const { executeAsync, isExecuting } = useAction(updateUserRoleInFolder, {
+ const { executeAsync, isPending } = useAction(updateUserRoleInFolder, {
onSuccess: () => {
toast.success("Role updated!");
},
@@ -213,7 +213,7 @@ const FolderUserRow = ({
});
const isCurrentUser = user.email === session?.user?.email;
- const disableRoleUpdate = !canUpdateRole || isExecuting || isCurrentUser;
+ const disableRoleUpdate = !canUpdateRole || isPending || isCurrentUser;
return (
{
- const { workspace } = ctx;
- const { name, cookieLength, domain } = parsedInput;
-
- if (domain) {
- await prisma.domain.findUniqueOrThrow({
- where: {
- slug: domain,
- projectId: workspace.id,
- },
- });
- }
-
- await prisma.program.create({
- data: {
- workspaceId: workspace.id,
- name,
- slug: slugify(name),
- cookieLength,
- domain,
- },
- });
- });
diff --git a/apps/web/lib/actions/partners/onboard-program.ts b/apps/web/lib/actions/partners/onboard-program.ts
new file mode 100644
index 0000000000..8c0bb983f9
--- /dev/null
+++ b/apps/web/lib/actions/partners/onboard-program.ts
@@ -0,0 +1,274 @@
+"use server";
+
+import { createLink, processLink } from "@/lib/api/links";
+import { createId } from "@/lib/api/utils";
+import { validateAllowedHostnames } from "@/lib/api/validate-allowed-hostnames";
+import { rewardfulImporter } from "@/lib/rewardful/importer";
+import { storage } from "@/lib/storage";
+import { PlanProps } from "@/lib/types";
+import {
+ onboardProgramSchema,
+ programDataSchema,
+} from "@/lib/zod/schemas/program-onboarding";
+import { sendEmail } from "@dub/email";
+import { PartnerInvite } from "@dub/email/templates/partner-invite";
+import { prisma } from "@dub/prisma";
+import { nanoid } from "@dub/utils";
+import { Program, Project, Reward, User } from "@prisma/client";
+import slugify from "@sindresorhus/slugify";
+import { z } from "zod";
+import { authActionClient } from "../safe-action";
+
+export const onboardProgramAction = authActionClient
+ .schema(onboardProgramSchema)
+ .action(async ({ ctx, parsedInput: data }) => {
+ const { workspace, user } = ctx;
+
+ const programsCount = await prisma.program.count({
+ where: {
+ workspaceId: workspace.id,
+ },
+ });
+
+ if (programsCount > 0 || !workspace.partnersEnabled) {
+ throw new Error("You are not allowed to create a new program.");
+ }
+
+ await handleOnboardingProgress({
+ data,
+ workspace,
+ });
+
+ if (data.step === "create-program") {
+ return await createProgram({
+ workspace,
+ user,
+ });
+ }
+ });
+
+const handleOnboardingProgress = async ({
+ workspace,
+ data,
+}: {
+ workspace: Pick
;
+ data: z.infer;
+}) => {
+ const store =
+ (workspace.store as Record | undefined | null) ?? {};
+
+ const programId =
+ store?.programOnboarding?.programId ?? createId({ prefix: "prog_" });
+
+ await prisma.project.update({
+ where: {
+ id: workspace.id,
+ },
+ data: {
+ store: {
+ ...store,
+ programOnboarding: {
+ ...store.programOnboarding,
+ ...data,
+ programId,
+ },
+ },
+ },
+ });
+};
+
+const createProgram = async ({
+ workspace,
+ user,
+}: {
+ workspace: Pick;
+ user: Pick;
+}) => {
+ const store = workspace.store as Record;
+
+ const {
+ name,
+ domain,
+ url,
+ type,
+ amount,
+ maxDuration,
+ partners,
+ rewardful,
+ linkType,
+ logo,
+ } = programDataSchema.parse(store?.programOnboarding);
+
+ await prisma.domain.findUniqueOrThrow({
+ where: {
+ slug: domain!,
+ projectId: workspace.id,
+ },
+ });
+
+ // create a new program
+ const program = await prisma.program.create({
+ data: {
+ id: createId({ prefix: "prog_" }),
+ workspaceId: workspace.id,
+ name,
+ slug: slugify(name),
+ domain,
+ url,
+ },
+ });
+
+ const logoUrl = logo
+ ? await storage
+ .upload(`programs/${program.id}/logo_${nanoid(7)}`, logo)
+ .then(({ url }) => url)
+ : null;
+
+ let reward: Reward | null = null;
+
+ // create a new reward
+ if (type && amount && maxDuration) {
+ reward = await prisma.reward.create({
+ data: {
+ id: createId({ prefix: "rw_" }),
+ programId: program.id,
+ type,
+ amount,
+ maxDuration,
+ event: "sale",
+ },
+ });
+ }
+
+ await prisma.program.update({
+ where: {
+ id: program.id,
+ },
+ data: {
+ ...(reward && { defaultRewardId: reward.id }),
+ ...(logoUrl && { logo: logoUrl }),
+ },
+ });
+
+ // import the rewardful campaign if it exists
+ if (rewardful && rewardful.id) {
+ const credentials = await rewardfulImporter.getCredentials(workspace.id);
+
+ await rewardfulImporter.setCredentials(workspace.id, {
+ ...credentials,
+ campaignId: rewardful.id,
+ });
+
+ await rewardfulImporter.queue({
+ programId: program.id,
+ action: "import-campaign",
+ });
+ }
+
+ // invite the partners
+ if (partners && partners.length > 0) {
+ await Promise.all(
+ partners.map((partner) =>
+ invitePartner({
+ workspace,
+ program,
+ partner,
+ userId: user.id,
+ }),
+ ),
+ );
+ }
+
+ let validHostnames: string[] | undefined;
+
+ if (linkType === "query" && url) {
+ const hostname = new URL(url).hostname;
+
+ if (hostname) {
+ validHostnames = validateAllowedHostnames([hostname]);
+ }
+
+ console.log({ validHostnames });
+ }
+
+ // TODO:
+ // waitUntil is not working here
+
+ await prisma.project.update({
+ where: {
+ id: workspace.id,
+ },
+ data: {
+ ...(validHostnames && {
+ allowedHostnames: validHostnames,
+ }),
+ store: {
+ ...store,
+ programOnboarding: undefined,
+ },
+ },
+ });
+
+ return program;
+};
+
+async function invitePartner({
+ workspace,
+ program,
+ partner,
+ userId,
+}: {
+ workspace: Pick;
+ program: Pick;
+ partner: {
+ email: string;
+ key: string;
+ };
+ userId: string;
+}) {
+ const { link: partnerLink, error } = await processLink({
+ payload: {
+ url: program.url!,
+ domain: program.domain!,
+ key: partner.key,
+ programId: program.id,
+ trackConversion: true,
+ },
+ workspace: {
+ id: workspace.id,
+ plan: workspace.plan as PlanProps,
+ },
+ userId,
+ });
+
+ if (error != null) {
+ console.log("Error creating partner link", error);
+ return;
+ }
+
+ const link = await createLink(partnerLink);
+
+ await Promise.all([
+ prisma.programInvite.create({
+ data: {
+ id: createId({ prefix: "pgi_" }),
+ email: partner.email,
+ linkId: link.id,
+ programId: program.id,
+ },
+ }),
+
+ sendEmail({
+ subject: `${program.name} invited you to join Dub Partners`,
+ email: partner.email,
+ react: PartnerInvite({
+ email: partner.email,
+ appName: `${process.env.NEXT_PUBLIC_APP_NAME}`,
+ program: {
+ name: program.name,
+ logo: program.logo,
+ },
+ }),
+ }),
+ ]);
+}
diff --git a/apps/web/lib/actions/partners/set-rewardful-token.ts b/apps/web/lib/actions/partners/set-rewardful-token.ts
index 195b1da47d..796190cef6 100644
--- a/apps/web/lib/actions/partners/set-rewardful-token.ts
+++ b/apps/web/lib/actions/partners/set-rewardful-token.ts
@@ -3,12 +3,10 @@
import { RewardfulApi } from "@/lib/rewardful/api";
import { rewardfulImporter } from "@/lib/rewardful/importer";
import { z } from "zod";
-import { getProgramOrThrow } from "../../api/programs/get-program-or-throw";
import { authActionClient } from "../safe-action";
const schema = z.object({
workspaceId: z.string(),
- programId: z.string(),
token: z.string(),
});
@@ -16,14 +14,14 @@ export const setRewardfulTokenAction = authActionClient
.schema(schema)
.action(async ({ parsedInput, ctx }) => {
const { workspace, user } = ctx;
- const { token, programId } = parsedInput;
+ const { token } = parsedInput;
- await getProgramOrThrow({
- workspaceId: workspace.id,
- programId,
- });
+ if (!workspace.partnersEnabled) {
+ throw new Error("You are not allowed to perform this action.");
+ }
const rewardfulApi = new RewardfulApi({ token });
+
try {
await rewardfulApi.listCampaigns();
} catch (error) {
@@ -31,7 +29,7 @@ export const setRewardfulTokenAction = authActionClient
throw new Error("Invalid Rewardful token");
}
- await rewardfulImporter.setCredentials(programId, {
+ await rewardfulImporter.setCredentials(workspace.id, {
userId: user.id,
token,
campaignId: "", // We'll set in the second step after choosing the campaign
diff --git a/apps/web/lib/actions/partners/start-rewardful-import.ts b/apps/web/lib/actions/partners/start-rewardful-import.ts
index cfccfea7c6..57972fe478 100644
--- a/apps/web/lib/actions/partners/start-rewardful-import.ts
+++ b/apps/web/lib/actions/partners/start-rewardful-import.ts
@@ -30,9 +30,9 @@ export const startRewardfulImportAction = authActionClient
throw new Error("Program URL is not set.");
}
- const credentials = await rewardfulImporter.getCredentials(programId);
+ const credentials = await rewardfulImporter.getCredentials(workspace.id);
- await rewardfulImporter.setCredentials(programId, {
+ await rewardfulImporter.setCredentials(workspace.id, {
...credentials,
campaignId,
});
diff --git a/apps/web/lib/actions/update-workspace-store.ts b/apps/web/lib/actions/update-workspace-store.ts
index 6cb904be46..3f7b229401 100644
--- a/apps/web/lib/actions/update-workspace-store.ts
+++ b/apps/web/lib/actions/update-workspace-store.ts
@@ -10,6 +10,9 @@ const schema = z.object({
value: z.any(),
});
+// TODO:
+// Add validation for key and value, otherwise it is open to abuse
+
// Update a workspace store item
export const updateWorkspaceStore = authActionClient
.schema(schema)
diff --git a/apps/web/lib/api/domains/get-domain-or-throw.ts b/apps/web/lib/api/domains/get-domain-or-throw.ts
index d4160c9ebd..ccb2bf9f3c 100644
--- a/apps/web/lib/api/domains/get-domain-or-throw.ts
+++ b/apps/web/lib/api/domains/get-domain-or-throw.ts
@@ -1,6 +1,6 @@
-import { WorkspaceWithUsers } from "@/lib/types";
import { prisma } from "@dub/prisma";
import { DUB_WORKSPACE_ID, isDubDomain } from "@dub/utils";
+import { Project } from "@prisma/client";
import { DubApiError } from "../errors";
export const getDomainOrThrow = async ({
@@ -8,7 +8,7 @@ export const getDomainOrThrow = async ({
domain,
dubDomainChecks,
}: {
- workspace: WorkspaceWithUsers;
+ workspace: Pick;
domain: string;
dubDomainChecks?: boolean; // if we also need to make sure the user can actually make changes to dub default domains
}) => {
diff --git a/apps/web/lib/api/utils.ts b/apps/web/lib/api/utils.ts
index 5cfacaffef..15872a1057 100644
--- a/apps/web/lib/api/utils.ts
+++ b/apps/web/lib/api/utils.ts
@@ -73,6 +73,7 @@ const prefixes = [
"inv_",
"rw_",
"fold_",
+ "prog_",
] as const;
export const createId = ({
diff --git a/apps/web/lib/rewardful/import-affiliates.ts b/apps/web/lib/rewardful/import-affiliates.ts
index df647be5b9..37a83712eb 100644
--- a/apps/web/lib/rewardful/import-affiliates.ts
+++ b/apps/web/lib/rewardful/import-affiliates.ts
@@ -17,15 +17,6 @@ export async function importAffiliates({
rewardId?: string;
page: number;
}) {
- const { token, userId, campaignId } =
- await rewardfulImporter.getCredentials(programId);
-
- const rewardfulApi = new RewardfulApi({ token });
-
- let currentPage = page;
- let hasMoreAffiliates = true;
- let processedBatches = 0;
-
const { workspace, ...program } = await prisma.program.findUniqueOrThrow({
where: {
id: programId,
@@ -35,6 +26,16 @@ export async function importAffiliates({
},
});
+ const { token, userId, campaignId } = await rewardfulImporter.getCredentials(
+ workspace.id,
+ );
+
+ const rewardfulApi = new RewardfulApi({ token });
+
+ let currentPage = page;
+ let hasMoreAffiliates = true;
+ let processedBatches = 0;
+
while (hasMoreAffiliates && processedBatches < MAX_BATCHES) {
const affiliates = await rewardfulApi.listAffiliates({
campaignId,
diff --git a/apps/web/lib/rewardful/import-campaign.ts b/apps/web/lib/rewardful/import-campaign.ts
index b187ffc7f8..2fcde8d231 100644
--- a/apps/web/lib/rewardful/import-campaign.ts
+++ b/apps/web/lib/rewardful/import-campaign.ts
@@ -4,8 +4,18 @@ import { RewardfulApi } from "./api";
import { rewardfulImporter } from "./importer";
export async function importCampaign({ programId }: { programId: string }) {
- const { token, campaignId } =
- await rewardfulImporter.getCredentials(programId);
+ const { workspace } = await prisma.program.findUniqueOrThrow({
+ where: {
+ id: programId,
+ },
+ include: {
+ workspace: true,
+ },
+ });
+
+ const { token, campaignId } = await rewardfulImporter.getCredentials(
+ workspace.id,
+ );
const rewardfulApi = new RewardfulApi({ token });
diff --git a/apps/web/lib/rewardful/import-referrals.ts b/apps/web/lib/rewardful/import-referrals.ts
index e8416a49d0..50e53dad15 100644
--- a/apps/web/lib/rewardful/import-referrals.ts
+++ b/apps/web/lib/rewardful/import-referrals.ts
@@ -18,15 +18,6 @@ export async function importReferrals({
programId: string;
page: number;
}) {
- const { token, userId, campaignId } =
- await rewardfulImporter.getCredentials(programId);
-
- const rewardfulApi = new RewardfulApi({ token });
-
- let currentPage = page;
- let hasMoreReferrals = true;
- let processedBatches = 0;
-
const { workspace, ...program } = await prisma.program.findUniqueOrThrow({
where: {
id: programId,
@@ -36,6 +27,16 @@ export async function importReferrals({
},
});
+ const { token, userId, campaignId } = await rewardfulImporter.getCredentials(
+ workspace.id,
+ );
+
+ const rewardfulApi = new RewardfulApi({ token });
+
+ let currentPage = page;
+ let hasMoreReferrals = true;
+ let processedBatches = 0;
+
while (hasMoreReferrals && processedBatches < MAX_BATCHES) {
const referrals = await rewardfulApi.listReferrals({
page: currentPage,
@@ -71,7 +72,7 @@ export async function importReferrals({
}
// Imports finished
- await rewardfulImporter.deleteCredentials(programId);
+ await rewardfulImporter.deleteCredentials(workspace.id);
const { email } = await prisma.user.findUniqueOrThrow({
where: {
diff --git a/apps/web/lib/rewardful/importer.ts b/apps/web/lib/rewardful/importer.ts
index c88a184811..e1994b05f9 100644
--- a/apps/web/lib/rewardful/importer.ts
+++ b/apps/web/lib/rewardful/importer.ts
@@ -15,15 +15,15 @@ export const importSteps = z.enum([
]);
class RewardfulImporter {
- async setCredentials(programId: string, payload: RewardfulConfig) {
- await redis.set(`${CACHE_KEY_PREFIX}:${programId}`, payload, {
+ async setCredentials(workspaceId: string, payload: RewardfulConfig) {
+ await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, payload, {
ex: CACHE_EXPIRY,
});
}
- async getCredentials(programId: string): Promise {
+ async getCredentials(workspaceId: string): Promise {
const config = await redis.get(
- `${CACHE_KEY_PREFIX}:${programId}`,
+ `${CACHE_KEY_PREFIX}:${workspaceId}`,
);
if (!config) {
@@ -33,8 +33,8 @@ class RewardfulImporter {
return config;
}
- async deleteCredentials(programId: string) {
- return await redis.del(`${CACHE_KEY_PREFIX}:${programId}`);
+ async deleteCredentials(workspaceId: string) {
+ return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);
}
async queue(body: {
diff --git a/apps/web/lib/rewardful/types.ts b/apps/web/lib/rewardful/types.ts
index bfa5c4c626..f8a5c2c04f 100644
--- a/apps/web/lib/rewardful/types.ts
+++ b/apps/web/lib/rewardful/types.ts
@@ -13,7 +13,6 @@ export interface RewardfulCampaign {
max_commission_period_months: number;
reward_type: "amount" | "percent";
commission_percent: number;
- // stripe_coupon_id: string;
created_at: string;
updated_at: string;
}
diff --git a/apps/web/lib/swr/use-rewardful-campaigns.ts b/apps/web/lib/swr/use-rewardful-campaigns.ts
new file mode 100644
index 0000000000..6c4fe5831e
--- /dev/null
+++ b/apps/web/lib/swr/use-rewardful-campaigns.ts
@@ -0,0 +1,25 @@
+import { RewardfulCampaign } from "@/lib/rewardful/types";
+import { fetcher } from "@dub/utils/src";
+import useSWR from "swr";
+import useWorkspace from "./use-workspace";
+
+export const useRewardfulCampaigns = ({
+ enabled = false,
+}: {
+ enabled: boolean;
+}) => {
+ const { id: workspaceId } = useWorkspace();
+
+ const { data, error } = useSWR(
+ enabled && workspaceId
+ ? `/api/programs/rewardful/campaigns?workspaceId=${workspaceId}`
+ : null,
+ fetcher,
+ );
+
+ return {
+ campaigns: data,
+ loading: !data && !error && enabled,
+ error,
+ };
+};
diff --git a/apps/web/lib/swr/use-workspace-store.ts b/apps/web/lib/swr/use-workspace-store.ts
index fa3838d24a..3e0f95cf5e 100644
--- a/apps/web/lib/swr/use-workspace-store.ts
+++ b/apps/web/lib/swr/use-workspace-store.ts
@@ -12,7 +12,11 @@ export const STORE_KEYS = {
export function useWorkspaceStore(
key: string,
-): [T | undefined, (value: T) => Promise, { loading: boolean }] {
+): [
+ T | undefined,
+ (value: T) => Promise,
+ { loading: boolean; mutateWorkspace: () => void },
+] {
const { id: workspaceId, slug, store, loading } = useWorkspace();
const { executeAsync } = useAction(updateWorkspaceStore);
@@ -25,8 +29,11 @@ export function useWorkspaceStore(
const setItem = async (value: T) => {
setItemState(value);
await executeAsync({ key, value, workspaceId: workspaceId! });
+ };
+
+ const mutateWorkspace = () => {
mutate(`/api/workspaces/${slug}`);
};
- return [item, setItem, { loading }];
+ return [item, setItem, { loading, mutateWorkspace }];
}
diff --git a/apps/web/lib/zod/schemas/program-onboarding.ts b/apps/web/lib/zod/schemas/program-onboarding.ts
new file mode 100644
index 0000000000..ad3e1926f2
--- /dev/null
+++ b/apps/web/lib/zod/schemas/program-onboarding.ts
@@ -0,0 +1,100 @@
+import { CommissionType } from "@prisma/client";
+import { z } from "zod";
+import { RECURRING_MAX_DURATIONS } from "./rewards";
+
+// Getting started
+export const programInfoSchema = z.object({
+ name: z.string().max(100),
+ logo: z.string().nullish(),
+ domain: z.string().nullish(),
+ url: z.string().url("Enter a valid URL").max(255).nullish(),
+ linkType: z.enum(["short", "query", "dynamic"]).default("short"),
+});
+
+// Configure reward
+export const programRewardSchema = z
+ .object({
+ programType: z.enum(["new", "import"]),
+ rewardful: z
+ .object({
+ id: z.string(),
+ affiliates: z.number(),
+ commission_amount_cents: z.number().nullable(),
+ max_commission_period_months: z.number(),
+ reward_type: z.enum(["amount", "percent"]),
+ commission_percent: z.number().nullable(),
+ })
+ .nullish(),
+ })
+ .merge(
+ z.object({
+ type: z.nativeEnum(CommissionType).nullish(),
+ amount: z.number().min(0).nullish(),
+ maxDuration: z.coerce
+ .number()
+ .refine((val) => RECURRING_MAX_DURATIONS.includes(val), {
+ message: `Max duration must be ${RECURRING_MAX_DURATIONS.join(", ")}`,
+ })
+ .nullish(),
+ }),
+ );
+
+// Invite partners
+export const programInvitePartnersSchema = z.object({
+ partners: z
+ .array(
+ z.object({
+ email: z.string().email("Please enter a valid email"),
+ key: z.string().min(1, "Please enter a referral key"),
+ }),
+ )
+ .max(10, "You can only invite up to 10 partners.")
+ .nullable()
+ .transform(
+ (partners) =>
+ partners?.filter(
+ (partner) => partner.email.trim() && partner.key.trim(),
+ ) || null,
+ ),
+});
+
+export const programDataSchema = programInfoSchema
+ .merge(programRewardSchema)
+ .merge(programInvitePartnersSchema);
+
+export const onboardProgramSchema = z.discriminatedUnion("step", [
+ programInfoSchema.merge(
+ z.object({
+ step: z.literal("get-started"),
+ workspaceId: z.string(),
+ }),
+ ),
+
+ programRewardSchema.merge(
+ z.object({
+ step: z.literal("configure-reward"),
+ workspaceId: z.string(),
+ }),
+ ),
+
+ programInvitePartnersSchema.merge(
+ z.object({
+ step: z.literal("invite-partners"),
+ workspaceId: z.string(),
+ }),
+ ),
+
+ z.object({
+ step: z.literal("create-program"),
+ workspaceId: z.string(),
+ }),
+
+ programDataSchema.partial().merge(
+ z.object({
+ step: z.literal("save-and-exit"),
+ workspaceId: z.string(),
+ }),
+ ),
+]);
+
+export type ProgramData = z.infer;
diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts
index 4f4fbfc0c2..eff9a8ae01 100644
--- a/apps/web/lib/zod/schemas/rewards.ts
+++ b/apps/web/lib/zod/schemas/rewards.ts
@@ -4,6 +4,19 @@ import { getPaginationQuerySchema } from "./misc";
export const RECURRING_MAX_DURATIONS = [0, 3, 6, 12, 18, 24];
+export const COMMISSION_TYPES = [
+ {
+ value: "one-off",
+ label: "One-off",
+ description: "Pay a one-time payout",
+ },
+ {
+ value: "recurring",
+ label: "Recurring",
+ description: "Pay an ongoing payout",
+ },
+] as const;
+
export const RewardSchema = z.object({
id: z.string(),
event: z.nativeEnum(EventType),
diff --git a/apps/web/ui/folders/folder-permissions-panel.tsx b/apps/web/ui/folders/folder-permissions-panel.tsx
index fb4c69be82..59b2af7df0 100644
--- a/apps/web/ui/folders/folder-permissions-panel.tsx
+++ b/apps/web/ui/folders/folder-permissions-panel.tsx
@@ -283,7 +283,7 @@ const FolderUserRow = ({
"folders.users.write",
);
- const { executeAsync, isExecuting } = useAction(updateUserRoleInFolder, {
+ const { executeAsync, isPending } = useAction(updateUserRoleInFolder, {
onSuccess: () => {
toast.success("Role updated!");
},
@@ -293,7 +293,7 @@ const FolderUserRow = ({
});
const isCurrentUser = user.email === session?.user?.email;
- const disableRoleUpdate = !canUpdateRole || isExecuting || isCurrentUser;
+ const disableRoleUpdate = !canUpdateRole || isPending || isCurrentUser;
return (
diff --git a/apps/web/ui/folders/request-edit-button.tsx b/apps/web/ui/folders/request-edit-button.tsx
index 22c09ccfa2..a3a01b7f2a 100644
--- a/apps/web/ui/folders/request-edit-button.tsx
+++ b/apps/web/ui/folders/request-edit-button.tsx
@@ -24,21 +24,18 @@ export const RequestFolderEditAccessButton = ({
const [requestSent, setRequestSent] = useState(false);
const { accessRequests, isLoading } = useFolderAccessRequests();
- const { executeAsync, isExecuting } = useAction(
- requestFolderEditAccessAction,
- {
- onSuccess: async () => {
- toast.success("Request sent to folder owner.");
- setRequestSent(true);
- await mutate(
- (key) => typeof key === "string" && key.startsWith(`/api/folders`),
- );
- },
- onError: ({ error }) => {
- toast.error(error.serverError);
- },
+ const { executeAsync, isPending } = useAction(requestFolderEditAccessAction, {
+ onSuccess: async () => {
+ toast.success("Request sent to folder owner.");
+ setRequestSent(true);
+ await mutate(
+ (key) => typeof key === "string" && key.startsWith(`/api/folders`),
+ );
},
- );
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
const isRequested = accessRequests?.some(
(accessRequest) => accessRequest.folderId === folderId,
@@ -53,7 +50,7 @@ export const RequestFolderEditAccessButton = ({
return (
await executeAsync({
workspaceId,
diff --git a/apps/web/ui/layout/sidebar/create-program-card.tsx b/apps/web/ui/layout/sidebar/create-program-card.tsx
new file mode 100644
index 0000000000..0b5920231f
--- /dev/null
+++ b/apps/web/ui/layout/sidebar/create-program-card.tsx
@@ -0,0 +1,49 @@
+import usePrograms from "@/lib/swr/use-programs";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { buttonVariants, ConnectedDots4 } from "@dub/ui";
+import { cn } from "@dub/utils";
+import Link from "next/link";
+
+export function CreateProgramCard() {
+ const { programs, loading: programsLoading } = usePrograms();
+ const {
+ partnersEnabled,
+ slug,
+ loading: workspaceLoading,
+ store,
+ } = useWorkspace();
+
+ if (
+ !partnersEnabled ||
+ programsLoading ||
+ workspaceLoading ||
+ (programs && programs.length > 0)
+ ) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
Dub Partners
+
+ Grow your revenue on autopilot with Dub Partners
+
+
+
+
+ {store?.programOnboarding ? "Finish creating" : "Create program"}
+
+
+ );
+}
diff --git a/apps/web/ui/layout/sidebar/sidebar-nav.tsx b/apps/web/ui/layout/sidebar/sidebar-nav.tsx
index c711ebeab1..ac38297e31 100644
--- a/apps/web/ui/layout/sidebar/sidebar-nav.tsx
+++ b/apps/web/ui/layout/sidebar/sidebar-nav.tsx
@@ -11,6 +11,7 @@ import {
useMemo,
useState,
} from "react";
+import { CreateProgramCard } from "./create-program-card";
import UserDropdown from "./user-dropdown";
export type NavItemCommon = {
@@ -123,6 +124,9 @@ export function SidebarNav>({
))}
+
+
+
{showNews && (
{
toast.error(error.serverError);
@@ -60,7 +60,7 @@ function ImportRewardfulModal({
const {
executeAsync: startRewardfulImport,
- isExecuting: isStartingRewardfulImport,
+ isPending: isStartingRewardfulImport,
} = useAction(startRewardfulImportAction, {
onError: ({ error }) => {
toast.error(error.serverError);
@@ -73,6 +73,9 @@ function ImportRewardfulModal({
},
});
+ // TODO
+ // Replace this with new hook
+
const {
data: campaigns,
isLoading: isLoadingCampaigns,
@@ -82,7 +85,7 @@ function ImportRewardfulModal({
program?.id &&
workspaceId &&
step === "campaigns" &&
- `/api/programs/${program.id}/rewardful/campaigns?workspaceId=${workspaceId}`,
+ `/api/programs/rewardful/campaigns?workspaceId=${workspaceId}`,
fetcher,
);
@@ -104,7 +107,6 @@ function ImportRewardfulModal({
await setRewardfulToken({
workspaceId,
- programId: program.id,
token: apiToken,
});
};
diff --git a/apps/web/ui/modals/modal-provider.tsx b/apps/web/ui/modals/modal-provider.tsx
index f6bdeb3f71..3d9635c948 100644
--- a/apps/web/ui/modals/modal-provider.tsx
+++ b/apps/web/ui/modals/modal-provider.tsx
@@ -28,6 +28,7 @@ import { useAddEditTagModal } from "./add-edit-tag-modal";
import { useImportRebrandlyModal } from "./import-rebrandly-modal";
import { useImportRewardfulModal } from "./import-rewardful-modal";
import { useLinkBuilder } from "./link-builder";
+import { useProgramWelcomeModal } from "./program-welcome-modal";
import { useWelcomeModal } from "./welcome-modal";
export const ModalContext = createContext<{
@@ -98,16 +99,17 @@ function ModalProviderClient({ children }: { children: ReactNode }) {
useImportRebrandlyModal();
const { setShowImportCsvModal, ImportCsvModal } = useImportCsvModal();
const { setShowWelcomeModal, WelcomeModal } = useWelcomeModal();
+ const { setShowProgramWelcomeModal, ProgramWelcomeModal } =
+ useProgramWelcomeModal();
const { setShowImportRewardfulModal, ImportRewardfulModal } =
useImportRewardfulModal();
- useEffect(
- () =>
- setShowWelcomeModal(
- searchParams.has("onboarded") || searchParams.has("upgraded"),
- ),
- [searchParams],
- );
+ useEffect(() => {
+ setShowProgramWelcomeModal(searchParams.has("onboarded-program"));
+ setShowWelcomeModal(
+ searchParams.has("onboarded") || searchParams.has("upgraded"),
+ );
+ }, [searchParams]);
const [hashes, setHashes] = useCookies("hashes__dub", [], {
domain: !!process.env.NEXT_PUBLIC_VERCEL_URL ? ".dub.co" : undefined,
@@ -204,6 +206,7 @@ function ModalProviderClient({ children }: { children: ReactNode }) {
+
{children}
);
diff --git a/apps/web/ui/modals/program-welcome-modal.tsx b/apps/web/ui/modals/program-welcome-modal.tsx
new file mode 100644
index 0000000000..82176516c6
--- /dev/null
+++ b/apps/web/ui/modals/program-welcome-modal.tsx
@@ -0,0 +1,101 @@
+import useProgram from "@/lib/swr/use-program";
+import { Button, Modal, useRouterStuff, useScrollProgress } from "@dub/ui";
+import { cn } from "@dub/utils";
+import {
+ Dispatch,
+ SetStateAction,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { ModalHero } from "../shared/modal-hero";
+
+function ProgramWelcomeModal({
+ showProgramWelcomeModal,
+ setShowProgramWelcomeModal,
+}: {
+ showProgramWelcomeModal: boolean;
+ setShowProgramWelcomeModal: Dispatch>;
+}) {
+ const { program, loading } = useProgram();
+ const { queryParams } = useRouterStuff();
+ const scrollRef = useRef(null);
+ const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);
+
+ return (
+
+ queryParams({
+ del: ["program-onboarded"],
+ })
+ }
+ >
+
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ program?.name
+ )}
+
+
+ Congratulations on creating your first partner program with Dub!
+ Share your program application link with your customers and
+ fans, and track all their activity in you dashboard.
+
+
+
+
+
+
+
+ queryParams({
+ del: ["onboarded-program"],
+ })
+ }
+ />
+
+
+
+ );
+}
+
+export function useProgramWelcomeModal() {
+ const [showProgramWelcomeModal, setShowProgramWelcomeModal] = useState(false);
+
+ const ProgramWelcomeModalCallback = useCallback(() => {
+ return (
+
+ );
+ }, [showProgramWelcomeModal, setShowProgramWelcomeModal]);
+
+ return useMemo(
+ () => ({
+ setShowProgramWelcomeModal,
+ ProgramWelcomeModal: ProgramWelcomeModalCallback,
+ }),
+ [setShowProgramWelcomeModal, ProgramWelcomeModalCallback],
+ );
+}
diff --git a/apps/web/ui/partners/add-edit-reward-sheet.tsx b/apps/web/ui/partners/add-edit-reward-sheet.tsx
index 3373b6718d..a2998514be 100644
--- a/apps/web/ui/partners/add-edit-reward-sheet.tsx
+++ b/apps/web/ui/partners/add-edit-reward-sheet.tsx
@@ -12,6 +12,7 @@ import useRewards from "@/lib/swr/use-rewards";
import useWorkspace from "@/lib/swr/use-workspace";
import { EnrolledPartnerProps, RewardProps } from "@/lib/types";
import {
+ COMMISSION_TYPES,
createRewardSchema,
RECURRING_MAX_DURATIONS,
} from "@/lib/zod/schemas/rewards";
@@ -54,7 +55,7 @@ interface RewardSheetProps {
type FormData = z.infer;
-const partnerTypes = [
+const PARTNER_TYPES = [
{
key: "all",
label: "All Partners",
@@ -67,19 +68,6 @@ const partnerTypes = [
},
] as const;
-const commissionTypes = [
- {
- label: "One-off",
- description: "Pay a one-time payout",
- recurring: false,
- },
- {
- label: "Recurring",
- description: "Pay an ongoing payout",
- recurring: true,
- },
-] as const;
-
function RewardSheetContent({ setIsOpen, event, reward }: RewardSheetProps) {
const formRef = useRef(null);
@@ -91,7 +79,7 @@ function RewardSheetContent({ setIsOpen, event, reward }: RewardSheetProps) {
const [isAddPartnersOpen, setIsAddPartnersOpen] = useState(false);
const [selectedPartnerType, setSelectedPartnerType] =
- useState<(typeof partnerTypes)[number]["key"]>("all");
+ useState<(typeof PARTNER_TYPES)[number]["key"]>("all");
const [isRecurring, setIsRecurring] = useState(
reward ? reward.maxDuration !== 0 : false,
@@ -430,7 +418,7 @@ function RewardSheetContent({ setIsOpen, event, reward }: RewardSheetProps) {
{event !== "sale" && (
- {partnerTypes.map((partnerType) => {
+ {PARTNER_TYPES.map((partnerType) => {
const isSelected = selectedPartnerType === partnerType.key;
const isDisabled =
@@ -516,9 +504,10 @@ function RewardSheetContent({ setIsOpen, event, reward }: RewardSheetProps) {
>
- {commissionTypes.map(
- ({ label, description, recurring }) => {
- const isSelected = isRecurring === recurring;
+ {COMMISSION_TYPES.map(
+ ({ label, description, value }) => {
+ const isSelected =
+ (value === "recurring") === isRecurring;
return (
{
if (e.target.checked) {
- setIsRecurring(recurring);
+ setIsRecurring(value === "recurring");
setValue(
"maxDuration",
- recurring
+ value === "recurring"
? reward?.maxDuration || 3
: 0,
);
diff --git a/apps/web/ui/partners/program-reward-description.tsx b/apps/web/ui/partners/program-reward-description.tsx
index 82219ce780..789fe183a9 100644
--- a/apps/web/ui/partners/program-reward-description.tsx
+++ b/apps/web/ui/partners/program-reward-description.tsx
@@ -9,7 +9,10 @@ export function ProgramRewardDescription({
periodClassName,
hideIfZero = true,
}: {
- reward?: RewardProps | null;
+ reward?: Pick<
+ RewardProps,
+ "amount" | "type" | "event" | "maxDuration"
+ > | null;
discount?: DiscountProps | null;
amountClassName?: string;
periodClassName?: string;