Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const inputSchema = z.object({
provider: z.enum(DiscountProvider),
});

// POST /api/cron/discount-codes/delete
// POST /api/cron/discount-codes/disable – disable a discount code from the provider (Stripe, Shopify, etc.)
export const POST = withCron(async ({ rawBody }) => {
const { provider, code, programId } = inputSchema.parse(JSON.parse(rawBody));

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(ee)/api/cron/partners/ban/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export const POST = withCron(async ({ rawBody }) => {
recordLink(links, { deleted: true }),

// Queue discount code deletions
deleteDiscountCodes(discountCodes),
deleteDiscountCodes(discountCodes, { isSoftDelete: true }),
]);

// Send email
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(ee)/api/cron/partners/deactivate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const POST = withCron(async ({ rawBody }) => {
const discountCodes = programEnrollments.flatMap(({ discountCodes }) =>
discountCodes.map((dc) => dc),
);
await deleteDiscountCodes(discountCodes);
await deleteDiscountCodes(discountCodes, { isSoftDelete: true });
Comment thread
steven-tey marked this conversation as resolved.
console.log("[bulkDeactivatePartners] Queued discount code deletions.");

// Find the program
Expand Down
8 changes: 6 additions & 2 deletions apps/web/app/(ee)/api/discount-codes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DiscountCodeSchema,
getDiscountCodesQuerySchema,
} from "@/lib/zod/schemas/discount";
import { APP_DOMAIN } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

Expand Down Expand Up @@ -100,12 +101,15 @@ export const POST = withWorkspace(
code,
},
},
include: {
partner: true,
},
});

if (duplicateByCode) {
throw new DubApiError({
code: "bad_request",
message: `A discount with the code ${code} already exists in the program. Please choose a different code.`,
code: "conflict",
message: `This discount code "${code}" is already in use by [${duplicateByCode.partner.email}](${APP_DOMAIN}/${workspace.slug}/program/partners/${duplicateByCode.partner.id}). Please choose a different code.`,
});
}
Comment thread
steven-tey marked this conversation as resolved.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
return {
...link,
discountCode: discountCode?.code,
discountCodeDisabledAt: discountCode?.disabledAt ?? null,
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,21 @@ export async function attributeViaPromotionCodeId({
code: promotionCode.code,
},
},
select: {
include: {
link: true,
},
});

if (!discountCode) {
console.log(
`Couldn't find link associated with promotion code ${promotionCode.code}, skipping...`,
`Couldn't find discount code "${promotionCode.code}" in program "${workspace.defaultProgramId}", skipping...`,
);
return null;
}

if (discountCode.disabledAt) {
console.log(
`Discount code "${discountCode.code}" is disabled, skipping...`,
);
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getApexDomain,
getPrettyUrl,
nFormatter,
PARTNERS_DOMAIN,
} from "@dub/utils";
import NumberFlow from "@number-flow/react";
import Link from "next/link";
Expand Down Expand Up @@ -75,6 +76,19 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {

const isDeactivated = programEnrollment?.status === "deactivated";

const discountCodeSection = link.discountCode ? (
<div className="hidden items-center gap-1.5 rounded-xl border border-neutral-200 py-1 pl-2 pr-1 sm:flex">
<span className="text-sm leading-none text-neutral-500">
Discount code
</span>
<DiscountCodeBadge
code={link.discountCode}
disabledAt={link.discountCodeDisabledAt}
disabledTooltip={`This discount code was disabled by the program. [Contact the program owner](${PARTNERS_DOMAIN}/messages/${programEnrollment?.program.slug}) if you need a new code.`}
/>
</div>
) : null;

return (
<CardList.Card
innerClassName={cn("px-0 py-0 group/card", isDeactivated && "opacity-80")}
Expand Down Expand Up @@ -153,20 +167,14 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {
</StatusBadge>
);
})()}
{link.discountCode && (
<Tooltip
content={
"This program supports discount code tracking. Copy the code to use it in podcasts, videos, etc. [Learn more](https://dub.co/help/article/dual-sided-incentives)"
}
>
<div className="hidden items-center gap-1.5 rounded-xl border border-neutral-200 py-1 pl-2 pr-1 sm:flex">
<span className="text-sm leading-none text-neutral-500">
Discount code
</span>
<DiscountCodeBadge code={link.discountCode} />
</div>
</Tooltip>
)}
{discountCodeSection &&
(link.discountCodeDisabledAt ? (
discountCodeSection
) : (
<Tooltip content="This program supports discount code tracking. Copy the code to use it in podcasts, videos, etc. [Learn more](https://dub.co/help/article/dual-sided-incentives)">
{discountCodeSection}
</Tooltip>
))}
{displayOption === "cards" && <StatsBadge link={link} />}
<Controls link={link} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,12 @@ const PartnerDiscountCodes = ({
{
id: "code",
header: "Code",
cell: ({ row }) => <DiscountCodeBadge code={row.original.code} />,
cell: ({ row }) => (
<DiscountCodeBadge
code={row.original.code}
disabledAt={row.original.disabledAt}
/>
),
},
{
id: "shortLink",
Expand Down
41 changes: 30 additions & 11 deletions apps/web/lib/discounts/delete-discount-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type DeleteDiscountCodesParams = Pick<
// 4. When a partner is moved to a different group
export async function deleteDiscountCodes(
input: (DeleteDiscountCodesParams | null | undefined)[],
{ isSoftDelete = false }: { isSoftDelete?: boolean } = {},
) {
const discountCodes = input.filter(
(dc): dc is NonNullable<typeof dc> => dc != null,
Expand All @@ -29,18 +30,36 @@ export async function deleteDiscountCodes(
return;
}

// Delete the discount codes from the database
const deletedDiscountCodes = await prisma.discountCode.deleteMany({
where: {
id: {
in: discountCodes.map(({ id }) => id),
if (isSoftDelete) {
// Soft delete the discount codes from the database (mark them as disabled)
const disabledDiscountCodes = await prisma.discountCode.updateMany({
where: {
id: {
in: discountCodes.map(({ id }) => id),
},
},
data: {
disabledAt: new Date(),
},
},
});
});

console.log(
`[deleteDiscountCodes] Deleted ${deletedDiscountCodes.count} discount codes.`,
);
console.log(
`[deleteDiscountCodes] Disabled ${disabledDiscountCodes.count} discount codes.`,
);
} else {
// Delete the discount codes from the database
const deletedDiscountCodes = await prisma.discountCode.deleteMany({
where: {
id: {
in: discountCodes.map(({ id }) => id),
},
},
});

console.log(
`[deleteDiscountCodes] Deleted ${deletedDiscountCodes.count} discount codes.`,
);
}

// Only enqueue external-provider cleanup for codes whose provider is known.
// Orphaned codes (discount relation is null) still get deleted locally above
Expand All @@ -63,7 +82,7 @@ export async function deleteDiscountCodes(
for (const chunkOfCodes of chunks) {
await enqueueBatchJobs(
chunkOfCodes.map((discountCode) => ({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/delete`,
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/disable`,
method: "POST",
queueName: "delete-discount-code",
body: {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/zod/schemas/discount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ export const DiscountCodeSchema = z.object({
discountId: z.string().nullable(),
partnerId: z.string(),
linkId: z.string(),
disabledAt: z.coerce
.date()
.nullish()
.describe(
"When this discount code was disabled, which happens when a partner is banned or deactivated.",
),
Comment thread
steven-tey marked this conversation as resolved.
});

export const createDiscountCodeSchema = z.object({
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/zod/schemas/partner-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const PartnerProfileLinkSchema = LinkSchema.pick({
createdAt: z.string().or(z.date()),
partnerGroupDefaultLinkId: z.string().nullish(),
discountCode: z.string().nullable().default(null),
discountCodeDisabledAt: z.coerce.date().nullable().default(null),
});

export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
"remark-gfm": "^4.0.0",
"sanitize-html": "^2.17.0",
"shiki": "^1.14.1",
"sonner": "^1.4.41",
"sonner": "^2.0.7",
"streamdown": "^2.3.0",
"stripe": "^18.2.0",
"svix": "^1.76.1",
Expand Down
1 change: 1 addition & 0 deletions apps/web/prisma/schema/discount.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ model DiscountCode {
linkId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?

program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
discount Discount? @relation(fields: [discountId], references: [id], onDelete: SetNull)
Expand Down
43 changes: 23 additions & 20 deletions apps/web/ui/modals/add-discount-code-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import * as z from "zod/v4";
import { ERROR_MAP } from "../partners/constants";
import { X } from "../shared/icons";
import { CustomToast } from "../shared/custom-toast";
import { AlertCircleFill, X } from "../shared/icons";
Comment thread
steven-tey marked this conversation as resolved.
import { UpgradeRequiredToast } from "../shared/upgrade-required-toast";

type FormData = z.infer<typeof createDiscountCodeSchema>;
Expand Down Expand Up @@ -87,28 +88,30 @@ const AddDiscountCodeModal = ({
toast.success("Discount code created and copied to clipboard!");
},
onError: (error) => {
if (error) {
const code = Object.keys(ERROR_MAP).find((key) =>
error.startsWith(key),
);
const code = Object.keys(ERROR_MAP).find((key) =>
error.startsWith(key),
);

if (code) {
const { title, ctaLabel, ctaUrl } = ERROR_MAP[code];
const message = error.replace(`${code}: `, "");
if (code) {
const { title, ctaLabel, ctaUrl } = ERROR_MAP[code];
const message = error.replace(`${code}: `, "");

toast.custom(() => (
<UpgradeRequiredToast
title={title}
message={message}
ctaLabel={ctaLabel}
ctaUrl={ctaUrl}
/>
));
return;
}
toast.custom(() => (
<UpgradeRequiredToast
title={title}
message={message}
ctaLabel={ctaLabel}
ctaUrl={ctaUrl}
/>
));
return;
} else if (error.includes("already in use")) {
toast.custom(() => (
<CustomToast icon={AlertCircleFill}>{error}</CustomToast>
));
} else {
toast.error(error);
}

toast.error(error);
},
});
};
Expand Down
51 changes: 45 additions & 6 deletions apps/web/ui/partners/discounts/discount-code-badge.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
import { Tag, useCopyToClipboard } from "@dub/ui";
import { Tag, Tooltip, useCopyToClipboard } from "@dub/ui";
import { cn } from "@dub/utils";
import { toast } from "sonner";

export function DiscountCodeBadge({ code }: { code: string }) {
export function DiscountCodeBadge({
code,
disabledAt,
disabledTooltip = "This discount code was disabled because the partner was banned or deactivated. To re-enable it, delete this code and create a new one.",
}: {
code: string;
disabledAt?: Date | string | null;
disabledTooltip?: string;
}) {
const [copied, copyToClipboard] = useCopyToClipboard();
const isDisabled = !!disabledAt;

const content = (
<>
<Tag
className={cn(
"size-3",
isDisabled ? "text-neutral-500" : "text-green-700",
)}
strokeWidth={1.5}
/>
<div
className={cn(
"text-xs font-medium",
isDisabled
? "text-neutral-500 line-through"
: "text-green-700 decoration-dotted underline-offset-2 transition-colors group-hover/discountcode:underline",
)}
>
{code}
</div>
</>
);

if (isDisabled) {
return (
<Tooltip content={disabledTooltip}>
<div className="flex w-fit cursor-help items-center gap-1 rounded-lg bg-neutral-100 px-2 py-1">
{content}
</div>
</Tooltip>
);
}

return (
<button
type="button"
Expand All @@ -20,10 +62,7 @@ export function DiscountCodeBadge({ code }: { code: string }) {
})
}
>
<Tag className="size-3 text-green-700" strokeWidth={1.5} />
<div className="text-xs font-medium text-green-700 decoration-dotted underline-offset-2 transition-colors group-hover/discountcode:underline">
{code}
</div>
{content}
</button>
);
}
Loading
Loading