Skip to content

Commit 35d0100

Browse files
committed
Support deleting partner link if no stats
1 parent ac98e03 commit 35d0100

File tree

6 files changed

+285
-9
lines changed

6 files changed

+285
-9
lines changed

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DubApiError, ErrorCodes } from "@/lib/api/errors";
2-
import { processLink, updateLink } from "@/lib/api/links";
2+
import { deleteLink, processLink, updateLink } from "@/lib/api/links";
33
import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url";
44
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
55
import { parseRequestBody } from "@/lib/api/utils";
@@ -154,3 +154,46 @@ export const PATCH = withPartnerProfile(
154154
return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink));
155155
},
156156
);
157+
158+
// DELETE /api/partner-profile/[programId]/links/[linkId] - delete a link for a partner
159+
export const DELETE = withPartnerProfile(async ({ partner, params }) => {
160+
const { programId, linkId } = params;
161+
162+
const { links, status } = await getProgramEnrollmentOrThrow({
163+
partnerId: partner.id,
164+
programId,
165+
include: {
166+
links: true,
167+
},
168+
});
169+
170+
if (["banned", "deactivated"].includes(status)) {
171+
throw new DubApiError({
172+
code: "forbidden",
173+
message: "You are banned from this program.",
174+
});
175+
}
176+
177+
const link = links.find((link) => link.id === linkId);
178+
179+
if (!link) {
180+
throw new DubApiError({
181+
code: "not_found",
182+
message: "Link not found.",
183+
});
184+
}
185+
186+
// Check if link has any clicks, leads, or sales
187+
if (link.clicks > 0 || link.leads > 0 || link.saleAmount > 0) {
188+
throw new DubApiError({
189+
code: "bad_request",
190+
message:
191+
"You can only delete links with 0 clicks, 0 leads, and $0 in sales.",
192+
});
193+
}
194+
195+
// Delete the link
196+
await deleteLink(link.id);
197+
198+
return NextResponse.json({ id: link.id });
199+
});

apps/web/app/(ee)/api/partners/links/upsert/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const PUT = withWorkspace(
4141
throw new DubApiError({
4242
code: "bad_request",
4343
message:
44-
"You need to set a domain and url for this program before creating a partner.",
44+
"You need to set a domain and url for this program before upserting a partner link.",
4545
});
4646
}
4747

apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-controls.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { PartnerProfileLinkProps } from "@/lib/types";
2+
import { useDeletePartnerLinkModal } from "@/ui/modals/delete-partner-link-modal";
23
import { usePartnerLinkModal } from "@/ui/modals/partner-link-modal";
34
import { usePartnerLinkQRModal } from "@/ui/modals/partner-link-qr-modal";
45
import { ThreeDots } from "@/ui/shared/icons";
56
import { Button, PenWriting, Popover, useKeyboardShortcut } from "@dub/ui";
6-
import { QRCode } from "@dub/ui/icons";
7+
import { QRCode, Trash } from "@dub/ui/icons";
78
import { CopyPlus } from "lucide-react";
89

910
export function PartnerLinkControls({
@@ -39,8 +40,19 @@ export function PartnerLinkControls({
3940
},
4041
});
4142

43+
const canDelete =
44+
link.clicks === 0 && link.leads === 0 && link.saleAmount === 0;
45+
46+
const { setShowDeletePartnerLinkModal, DeletePartnerLinkModal } =
47+
useDeletePartnerLinkModal({
48+
link,
49+
onSuccess: () => {
50+
setOpenPopover(false);
51+
},
52+
});
53+
4254
useKeyboardShortcut(
43-
["e", "q", "d"],
55+
["e", "q", "d", "x"],
4456
(e) => {
4557
setOpenPopover(false);
4658
switch (e.key) {
@@ -53,6 +65,11 @@ export function PartnerLinkControls({
5365
case "d":
5466
setShowDuplicateLinkModal(true);
5567
break;
68+
case "x":
69+
if (canDelete) {
70+
setShowDeletePartnerLinkModal(true);
71+
}
72+
break;
5673
}
5774
},
5875
{
@@ -63,6 +80,7 @@ export function PartnerLinkControls({
6380

6481
return (
6582
<div className="flex justify-end">
83+
<DeletePartnerLinkModal />
6684
<LinkQRModal />
6785
<PartnerLinkModal />
6886
<DuplicateLinkModal />
@@ -103,6 +121,23 @@ export function PartnerLinkControls({
103121
shortcut="D"
104122
className="h-9 px-2 font-medium"
105123
/>
124+
<Button
125+
text="Delete"
126+
variant="danger-outline"
127+
onClick={() => {
128+
setOpenPopover(false);
129+
setShowDeletePartnerLinkModal(true);
130+
}}
131+
icon={<Trash className="size-4" />}
132+
shortcut="X"
133+
className="h-9 px-2 font-medium"
134+
disabled={!canDelete}
135+
disabledTooltip={
136+
!canDelete
137+
? "You can only delete links with 0 clicks, 0 leads, and $0 in sales."
138+
: undefined
139+
}
140+
/>
106141
</div>
107142
</div>
108143
}

apps/web/lib/openapi/partners/upsert-partner-link.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { openApiErrorResponses } from "@/lib/openapi/responses";
22
import { LinkSchema } from "@/lib/zod/schemas/links";
3-
import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners";
3+
import { upsertPartnerLinkSchema } from "@/lib/zod/schemas/partners";
44
import { ZodOpenApiOperationObject } from "zod-openapi";
55

66
export const upsertPartnerLink: ZodOpenApiOperationObject = {
@@ -12,7 +12,7 @@ export const upsertPartnerLink: ZodOpenApiOperationObject = {
1212
requestBody: {
1313
content: {
1414
"application/json": {
15-
schema: createPartnerLinkSchema,
15+
schema: upsertPartnerLinkSchema,
1616
},
1717
},
1818
},

apps/web/ui/links/link-controls.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ import {
1818
Copy,
1919
FolderBookmark,
2020
QRCode,
21+
Trash,
2122
} from "@dub/ui/icons";
2223
import { cn, isDubDomain, nanoid } from "@dub/utils";
23-
import { CopyPlus, Delete, FolderInput } from "lucide-react";
24+
import { CopyPlus, FolderInput } from "lucide-react";
2425
import { useParams, useRouter } from "next/navigation";
2526
import { useCallback } from "react";
2627
import { toast } from "sonner";
@@ -336,7 +337,7 @@ export function LinkControls({
336337
setOpenPopover(false);
337338
setShowDeleteLinkModal(true);
338339
}}
339-
icon={<Delete className="size-4" />}
340+
icon={<Trash className="size-4" />}
340341
shortcut="X"
341342
className="h-9 px-2 font-medium"
342343
disabled={isRootLink || isProgramLinkWithLeads}
@@ -358,7 +359,7 @@ export function LinkControls({
358359
onClick={() => handleBanLink()}
359360
className="group flex w-full items-center justify-between rounded-md p-2 text-left text-sm font-medium text-red-600 transition-all duration-75 hover:bg-red-600 hover:text-white"
360361
>
361-
<IconMenu text="Ban" icon={<Delete className="size-4" />} />
362+
<IconMenu text="Ban" icon={<Trash className="size-4" />} />
362363
<kbd className="hidden rounded bg-red-100 px-2 py-0.5 text-xs font-light text-red-600 transition-all duration-75 group-hover:bg-red-500 group-hover:text-white sm:inline-block">
363364
B
364365
</kbd>
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { constructPartnerLink } from "@/lib/partners/construct-partner-link";
2+
import useProgramEnrollment from "@/lib/swr/use-program-enrollment";
3+
import { PartnerProfileLinkProps } from "@/lib/types";
4+
import { SimpleLinkCard } from "@/ui/links/simple-link-card";
5+
import { Button, Modal, useMediaQuery } from "@dub/ui";
6+
import { getPrettyUrl } from "@dub/utils";
7+
import { useParams } from "next/navigation";
8+
import {
9+
Dispatch,
10+
SetStateAction,
11+
useCallback,
12+
useEffect,
13+
useMemo,
14+
useRef,
15+
useState,
16+
} from "react";
17+
import { toast } from "sonner";
18+
import { mutate } from "swr";
19+
20+
type DeletePartnerLinkModalProps = {
21+
showDeletePartnerLinkModal: boolean;
22+
setShowDeletePartnerLinkModal: Dispatch<SetStateAction<boolean>>;
23+
link: PartnerProfileLinkProps;
24+
onSuccess?: () => void;
25+
};
26+
27+
function DeletePartnerLinkModal(props: DeletePartnerLinkModalProps) {
28+
return (
29+
<Modal
30+
showModal={props.showDeletePartnerLinkModal}
31+
setShowModal={props.setShowDeletePartnerLinkModal}
32+
>
33+
<DeletePartnerLinkModalInner {...props} />
34+
</Modal>
35+
);
36+
}
37+
38+
function DeletePartnerLinkModalInner({
39+
setShowDeletePartnerLinkModal,
40+
link,
41+
onSuccess,
42+
}: DeletePartnerLinkModalProps) {
43+
const { programSlug } = useParams();
44+
const { programEnrollment } = useProgramEnrollment();
45+
const { isMobile } = useMediaQuery();
46+
const [deleting, setDeleting] = useState(false);
47+
48+
const partnerLink = constructPartnerLink({
49+
group: programEnrollment?.group,
50+
link,
51+
});
52+
53+
const pattern = getPrettyUrl(partnerLink);
54+
55+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
56+
e.preventDefault();
57+
if (!programEnrollment?.program.id) return;
58+
59+
setDeleting(true);
60+
61+
try {
62+
const response = await fetch(
63+
`/api/partner-profile/programs/${programEnrollment.program.id}/links/${link.id}`,
64+
{
65+
method: "DELETE",
66+
headers: { "Content-Type": "application/json" },
67+
},
68+
);
69+
70+
if (!response.ok) {
71+
const { error } = await response.json();
72+
throw new Error(error.message);
73+
}
74+
75+
await mutate(`/api/partner-profile/programs/${programSlug}/links`);
76+
setShowDeletePartnerLinkModal(false);
77+
onSuccess?.();
78+
toast.success("Link deleted successfully!");
79+
} catch (error) {
80+
toast.error(
81+
error instanceof Error
82+
? error.message
83+
: "Failed to delete link. Please try again.",
84+
);
85+
} finally {
86+
setDeleting(false);
87+
}
88+
};
89+
90+
return (
91+
<>
92+
<div className="space-y-2 border-b border-neutral-200 p-4 sm:p-6">
93+
<h3 className="text-lg font-medium leading-none">Delete link</h3>
94+
</div>
95+
96+
<div className="bg-neutral-50 p-4 sm:p-6">
97+
<p className="text-sm text-neutral-800">
98+
Are you sure you want to delete this link?
99+
</p>
100+
101+
<p className="mt-4 text-sm font-medium text-neutral-800">
102+
Deleting this link will remove all of its analytics. This action
103+
cannot be undone – proceed with caution.
104+
</p>
105+
106+
<div className="mt-4 rounded-2xl border border-neutral-200 p-2">
107+
<SimpleLinkCard
108+
link={{
109+
shortLink: partnerLink,
110+
url: link.url,
111+
}}
112+
/>
113+
</div>
114+
</div>
115+
116+
<form
117+
onSubmit={handleSubmit}
118+
className="flex flex-col bg-neutral-50 text-left"
119+
>
120+
<div className="px-4 sm:px-6">
121+
<label
122+
htmlFor="verification"
123+
className="block text-sm text-neutral-700"
124+
>
125+
To verify, type <span className="font-semibold">{pattern}</span>{" "}
126+
below
127+
</label>
128+
<div className="relative mt-1.5 rounded-md shadow-sm">
129+
<input
130+
type="text"
131+
name="verification"
132+
id="verification"
133+
pattern={pattern}
134+
required
135+
autoFocus={!isMobile}
136+
autoComplete="off"
137+
className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
138+
/>
139+
</div>
140+
</div>
141+
142+
<div className="mt-8 flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6">
143+
<Button
144+
onClick={() => setShowDeletePartnerLinkModal(false)}
145+
variant="secondary"
146+
text="Cancel"
147+
className="h-8 w-fit px-3"
148+
/>
149+
<Button
150+
variant="danger"
151+
text="Delete link"
152+
loading={deleting}
153+
className="h-8 w-fit px-3"
154+
/>
155+
</div>
156+
</form>
157+
</>
158+
);
159+
}
160+
161+
export function useDeletePartnerLinkModal({
162+
link,
163+
onSuccess,
164+
}: {
165+
link?: PartnerProfileLinkProps;
166+
onSuccess?: () => void;
167+
}) {
168+
const [showDeletePartnerLinkModal, setShowDeletePartnerLinkModal] =
169+
useState(false);
170+
171+
const linkRef = useRef(link);
172+
const onSuccessRef = useRef(onSuccess);
173+
174+
useEffect(() => {
175+
linkRef.current = link;
176+
onSuccessRef.current = onSuccess;
177+
}, [link, onSuccess]);
178+
179+
const DeletePartnerLinkModalCallback = useCallback(() => {
180+
return linkRef.current ? (
181+
<DeletePartnerLinkModal
182+
showDeletePartnerLinkModal={showDeletePartnerLinkModal}
183+
setShowDeletePartnerLinkModal={setShowDeletePartnerLinkModal}
184+
link={linkRef.current}
185+
onSuccess={onSuccessRef.current}
186+
/>
187+
) : null;
188+
}, [showDeletePartnerLinkModal, setShowDeletePartnerLinkModal]);
189+
190+
return useMemo(
191+
() => ({
192+
setShowDeletePartnerLinkModal,
193+
DeletePartnerLinkModal: DeletePartnerLinkModalCallback,
194+
}),
195+
[setShowDeletePartnerLinkModal, DeletePartnerLinkModalCallback],
196+
);
197+
}

0 commit comments

Comments
 (0)