Skip to content

Commit b7450af

Browse files
committed
Referrals Embed link creation flow
1 parent 8d118d1 commit b7450af

File tree

12 files changed

+457
-21
lines changed

12 files changed

+457
-21
lines changed

apps/web/app/api/embed/referrals/analytics/route.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth";
33
import { NextResponse } from "next/server";
44

55
// GET /api/embed/referrals/analytics – get timeseries analytics for a partner
6-
export const GET = withReferralsEmbedToken(async ({ programId, partnerId }) => {
6+
export const GET = withReferralsEmbedToken(async ({ programEnrollment }) => {
77
const analytics = await getAnalytics({
88
event: "composite",
99
groupBy: "timeseries",
1010
interval: "1y",
11-
programId,
12-
partnerId,
11+
programId: programEnrollment.programId,
12+
partnerId: programEnrollment.partnerId,
1313
});
1414

1515
return NextResponse.json(analytics);

apps/web/app/api/embed/referrals/earnings/route.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
77

88
// GET /api/embed/referrals/earnings – get commissions for a partner from an embed token
99
export const GET = withReferralsEmbedToken(
10-
async ({ programId, partnerId, searchParams }) => {
10+
async ({ programEnrollment, searchParams }) => {
1111
const { page } = z
1212
.object({ page: z.coerce.number().optional().default(1) })
1313
.parse(searchParams);
@@ -17,8 +17,8 @@ export const GET = withReferralsEmbedToken(
1717
earnings: {
1818
gt: 0,
1919
},
20-
programId,
21-
partnerId,
20+
programId: programEnrollment.programId,
21+
partnerId: programEnrollment.partnerId,
2222
},
2323
select: {
2424
id: true,

apps/web/app/api/embed/referrals/leaderboard/route.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { NextResponse } from "next/server";
55
import z from "node_modules/zod/lib";
66

77
// GET /api/embed/referrals/leaderboard – get leaderboard for a program
8-
export const GET = withReferralsEmbedToken(async ({ programId }) => {
8+
export const GET = withReferralsEmbedToken(async ({ program }) => {
99
const partners = await prisma.$queryRaw`
1010
SELECT
1111
p.id,
@@ -26,11 +26,11 @@ export const GET = withReferralsEmbedToken(async ({ programId }) => {
2626
SUM(sales) as totalSales,
2727
SUM(saleAmount) as totalSaleAmount
2828
FROM Link
29-
WHERE programId = ${programId}
29+
WHERE programId = ${program.id}
3030
GROUP BY partnerId
3131
) metrics ON metrics.partnerId = pe.partnerId
3232
WHERE
33-
pe.programId = ${programId}
33+
pe.programId = ${program.id}
3434
AND pe.status = 'approved'
3535
ORDER BY
3636
totalSaleAmount DESC,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { DubApiError, ErrorCodes } from "@/lib/api/errors";
2+
import { createLink, processLink } from "@/lib/api/links";
3+
import { parseRequestBody } from "@/lib/api/utils";
4+
import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth";
5+
import { LinkSchema } from "@/lib/zod/schemas/links";
6+
import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners";
7+
import { getApexDomain } from "@dub/utils";
8+
import { NextResponse } from "next/server";
9+
10+
// TODO: Move this to a constant
11+
const PARTNER_LINKS_LIMIT = 5;
12+
13+
// POST /api/embed/referrals/links – create links for a partner
14+
export const POST = withReferralsEmbedToken(
15+
async ({ req, programEnrollment, program, links }) => {
16+
const { url, key } = createPartnerLinkSchema
17+
.pick({ url: true, key: true })
18+
.parse(await parseRequestBody(req));
19+
20+
if (programEnrollment.status === "banned") {
21+
throw new DubApiError({
22+
code: "forbidden",
23+
message: "You are banned from this program hence cannot create links.",
24+
});
25+
}
26+
27+
if (!program.domain || !program.url) {
28+
throw new DubApiError({
29+
code: "bad_request",
30+
message:
31+
"This program needs a domain and URL set before creating a link.",
32+
});
33+
}
34+
35+
if (links.length >= PARTNER_LINKS_LIMIT) {
36+
throw new DubApiError({
37+
code: "bad_request",
38+
message: `You have reached the limit of ${PARTNER_LINKS_LIMIT} program links.`,
39+
});
40+
}
41+
42+
if (url && getApexDomain(url) !== getApexDomain(program.url)) {
43+
throw new DubApiError({
44+
code: "bad_request",
45+
message: `The provided URL domain (${getApexDomain(url)}) does not match the program's domain (${getApexDomain(program.url)}).`,
46+
});
47+
}
48+
49+
// TODO:
50+
// Under which workspace user the link should be created?
51+
52+
const { link, error, code } = await processLink({
53+
payload: {
54+
key: key || undefined,
55+
url: url || program.url,
56+
domain: program.domain,
57+
programId: program.id,
58+
folderId: program.defaultFolderId,
59+
tenantId: programEnrollment.tenantId,
60+
partnerId: programEnrollment.partnerId,
61+
trackConversion: true,
62+
},
63+
workspace: {
64+
id: program.workspaceId,
65+
plan: "business",
66+
},
67+
userId: "cm1ypncqa0000tc44pfgxp6qs", //session.user.id,
68+
skipFolderChecks: true, // can't be changed by the partner
69+
skipProgramChecks: true, // can't be changed by the partner
70+
skipExternalIdChecks: true, // can't be changed by the partner
71+
});
72+
73+
if (error != null) {
74+
throw new DubApiError({
75+
code: code as ErrorCodes,
76+
message: error,
77+
});
78+
}
79+
80+
const partnerLink = LinkSchema.pick({
81+
id: true,
82+
domain: true,
83+
key: true,
84+
url: true,
85+
}).parse(await createLink(link));
86+
87+
return NextResponse.json(partnerLink, { status: 201 });
88+
},
89+
);

apps/web/app/api/partner-profile/programs/[programId]/links/route.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const POST = withPartnerProfile(
8080
id: program.workspaceId,
8181
plan: "business",
8282
},
83-
userId: session.user.id,
83+
userId: session.user.id, // TODO: Hm, this is the partner user, not the workspace user?
8484
skipFolderChecks: true, // can't be changed by the partner
8585
skipProgramChecks: true, // can't be changed by the partner
8686
skipExternalIdChecks: true, // can't be changed by the partner
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { mutateSuffix } from "@/lib/swr/mutate";
2+
import {
3+
Button,
4+
InfoTooltip,
5+
SimpleTooltipContent,
6+
useCopyToClipboard,
7+
useMediaQuery,
8+
} from "@dub/ui";
9+
import { linkConstructor, TAB_ITEM_ANIMATION_SETTINGS } from "@dub/utils";
10+
import { motion } from "framer-motion";
11+
import { useMemo, useState } from "react";
12+
import { useForm } from "react-hook-form";
13+
14+
interface Props {
15+
destinationDomain: string;
16+
shortLinkDomain: string;
17+
}
18+
19+
interface FormData {
20+
url: string;
21+
key: string;
22+
}
23+
24+
export function ReferralsEmbedCreateLink({
25+
destinationDomain,
26+
shortLinkDomain,
27+
}: Props) {
28+
const { isMobile } = useMediaQuery();
29+
const [, copyToClipboard] = useCopyToClipboard();
30+
const [isSubmitting, setIsSubmitting] = useState(false);
31+
32+
const { watch, register, handleSubmit } = useForm<FormData>();
33+
34+
const [key, url] = watch("key", "url");
35+
36+
const onSubmit = async (data: FormData) => {
37+
setIsSubmitting(true);
38+
39+
try {
40+
const response = await fetch("/api/embed/referrals/links", {
41+
method: "POST",
42+
headers: {
43+
"Content-Type": "application/json",
44+
},
45+
body: JSON.stringify({
46+
...data,
47+
url: linkConstructor({
48+
domain: destinationDomain,
49+
key: data.url,
50+
}),
51+
}),
52+
});
53+
54+
const result = await response.json();
55+
56+
// TODO:
57+
// Display success or error message
58+
59+
if (!response.ok) {
60+
const { error } = result;
61+
return;
62+
}
63+
64+
await mutateSuffix("/links");
65+
} finally {
66+
setIsSubmitting(false);
67+
}
68+
};
69+
70+
const saveDisabled = useMemo(
71+
() => Boolean(isSubmitting || !key || !url),
72+
[isSubmitting, key, url],
73+
);
74+
75+
return (
76+
<motion.div
77+
className="border-border-subtle relative rounded-md border"
78+
{...TAB_ITEM_ANIMATION_SETTINGS}
79+
>
80+
<form
81+
onSubmit={handleSubmit(onSubmit)}
82+
className="max-h-[26rem] overflow-auto"
83+
>
84+
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
85+
<span className="text-base font-semibold">New link</span>
86+
<div className="flex items-center gap-x-2">
87+
<Button
88+
text="Cancel"
89+
variant="secondary"
90+
type="button"
91+
className="h-9"
92+
/>
93+
<Button
94+
text="Create link"
95+
variant="primary"
96+
loading={isSubmitting}
97+
disabled={saveDisabled}
98+
className="h-9"
99+
/>
100+
</div>
101+
</div>
102+
103+
<div className="space-y-6 p-6">
104+
<div>
105+
<div className="flex items-center gap-2">
106+
<label
107+
htmlFor="url"
108+
className="block text-sm font-medium text-neutral-700"
109+
>
110+
Destination URL
111+
</label>
112+
<InfoTooltip
113+
content={
114+
<SimpleTooltipContent
115+
title="The URL your users will get redirected to when they visit your referral link."
116+
cta="Learn more."
117+
href="https://dub.co/help/article/how-to-create-link"
118+
/>
119+
}
120+
/>
121+
</div>
122+
<div className="mt-2 flex rounded-md">
123+
<span className="inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
124+
{destinationDomain}
125+
</span>
126+
<input
127+
type="text"
128+
placeholder="about"
129+
className="block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
130+
{...register("url", { required: false })}
131+
autoFocus={!isMobile}
132+
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
133+
e.preventDefault();
134+
135+
// if pasting in a URL, extract the pathname
136+
const text = e.clipboardData.getData("text/plain");
137+
138+
try {
139+
const url = new URL(text);
140+
e.currentTarget.value = url.pathname.slice(1);
141+
} catch (err) {
142+
e.currentTarget.value = text;
143+
}
144+
}}
145+
/>
146+
</div>
147+
</div>
148+
149+
<div>
150+
<div className="flex items-center justify-between">
151+
<div className="flex items-center gap-2">
152+
<label
153+
htmlFor="short-link"
154+
className="block text-sm font-medium"
155+
>
156+
Short link
157+
</label>
158+
<InfoTooltip
159+
content={
160+
<SimpleTooltipContent
161+
title="The URL that will be shared with your users. Keep it short and memorable!"
162+
cta="Learn more."
163+
href="https://dub.co/help/article/how-to-create-link"
164+
/>
165+
}
166+
/>
167+
</div>
168+
</div>
169+
<div className="mt-2 flex rounded-md">
170+
<span className="inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
171+
{shortLinkDomain}
172+
</span>
173+
<input
174+
type="text"
175+
placeholder="another-link"
176+
className="block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
177+
{...register("key", { required: true })}
178+
/>
179+
</div>
180+
</div>
181+
</div>
182+
</form>
183+
184+
<div className="from-bg-default pointer-events-none absolute -bottom-px left-0 h-16 w-full rounded-b-lg bg-gradient-to-t sm:bottom-0" />
185+
</motion.div>
186+
);
187+
}

0 commit comments

Comments
 (0)