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 ( + + +
+
+
+ +
{children}
+
+
+
+
+ ); +} 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 ( + +
+ + +
+ +
+ +

+ A square logo that will be used in various parts of your program +

+
+ ( + field.onChange(src)} + content={null} + maxFileSizeMB={2} + /> + )} + /> +
+
+ +
+
+

+ Referral Link +

+

+ Set the default referral link domain and destination URL +

+
+ +
+ + {loading ? ( +
+ ) : ( + + )} +
+ +
+ + +
+
+ +
+
+

+ Link structure +

+

+ Set how the link shows up in the partner portal +

+
+ +
+ {LINK_TYPES.map((type) => { + const isSelected = watch("linkType") === type.value; + + 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} +
+ +
+
+ {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. +

+ +
+
+
+ Rewardful logo +
+ + Affiliates importing + +
+ + {rewardful?.affiliates} + +
+
+ )} + + +
+ {fields.map((field, index) => ( +
+
+ + +
+ +
+ +
+
+
+
+ + {domain} + +
+ handleKeyFocus(index)} + onChange={(e) => handleKeyChange(index, e.target.value)} + className={cn( + "w-full border-0 bg-transparent px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus:outline-none focus:ring-0", + keyErrors[index] && + "pr-10 text-red-900 placeholder-red-300", + )} + /> + + {keyErrors[index] && ( +
+ +
+ )} +
+ + {keyErrors[index] && ( +

+ {keyErrors[index]} +

+ )} +
+ + {index > 0 && ( +
+
+
+ ))} + +
+ +
+ ); +} 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 ( + +
+
+

+ Reward creation +

+

+ Create a brand new reward or import an existing program. +

+
+ +
+ {PROGRAM_TYPES.map((type) => { + const isSelected = programType === type.value; + + return ( + + ); + })} +
+
+ + {programType === "new" ? ( + + ) : ( + + )} + + {!hideContinueButton && ( + + +
{ + if (e.target === e.currentTarget) { + e.stopPropagation(); + setIsOpen(false); + } + }} + > +
+
+
+

Program Setup

+ +
+ +
+
+
+ + ); +} 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 (
))}
+ + + {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. +

+
+ +
+
+ +
+
+ + ); +} + +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 (