diff --git a/apps/web/lib/api/links/complete-ab-tests.ts b/apps/web/lib/api/links/complete-ab-tests.ts
index 17d42c306a..316e16be05 100644
--- a/apps/web/lib/api/links/complete-ab-tests.ts
+++ b/apps/web/lib/api/links/complete-ab-tests.ts
@@ -3,6 +3,7 @@ import { recordLink } from "@/lib/tinybird";
import { sendWorkspaceWebhook } from "@/lib/webhook/publish";
import { ABTestVariantsSchema, linkEventSchema } from "@/lib/zod/schemas/links";
import { prisma } from "@dub/prisma";
+import { normalizeUrl } from "@dub/utils";
import { Link } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { linkCache } from "./cache";
@@ -15,8 +16,15 @@ export async function completeABTests(link: Link) {
const testVariants = ABTestVariantsSchema.parse(link.testVariants);
- const analytics: { url: string; leads: number }[] = await getAnalytics({
- event: "leads",
+ // Fetch composite analytics (clicks, leads, sales, saleAmount) grouped by URLs
+ const analytics: {
+ url: string;
+ clicks: number;
+ leads: number;
+ sales: number;
+ saleAmount: number;
+ }[] = await getAnalytics({
+ event: "composite",
groupBy: "top_urls",
linkId: link.id,
workspaceId: link.projectId,
@@ -24,34 +32,125 @@ export async function completeABTests(link: Link) {
end: link.testCompletedAt,
});
- const max = Math.max(
- ...testVariants.map(
- (test) => analytics.find(({ url }) => url === test.url)?.leads || 0,
- ),
- );
+ // Aggregate analytics by normalized URL for stable matching with variants
+ const analyticsByNormalizedUrl = new Map<
+ string,
+ { clicks: number; leads: number; sales: number; saleAmount: number }
+ >();
- // There are no leads generated for any test variant, do nothing
- if (max === 0) {
- console.log(
- `AB Test completed but all results are zero for ${link.id}, doing nothing.`,
- );
- return;
+ for (const row of analytics || []) {
+ const key = normalizeUrl(row.url);
+ const existing = analyticsByNormalizedUrl.get(key);
+ if (existing) {
+ analyticsByNormalizedUrl.set(key, {
+ clicks: (existing.clicks || 0) + (row.clicks || 0),
+ leads: (existing.leads || 0) + (row.leads || 0),
+ sales: (existing.sales || 0) + (row.sales || 0),
+ saleAmount: (existing.saleAmount || 0) + (row.saleAmount || 0),
+ });
+ } else {
+ analyticsByNormalizedUrl.set(key, {
+ clicks: row.clicks || 0,
+ leads: row.leads || 0,
+ sales: row.sales || 0,
+ saleAmount: row.saleAmount || 0,
+ });
+ }
}
- const winners = testVariants.filter(
- (test) =>
- (analytics.find(({ url }) => url === test.url)?.leads || 0) === max,
- );
+ type VariantMetrics = {
+ url: string;
+ clicks: number;
+ leads: number;
+ conversions: number; // sales
+ };
+
+ const variants: VariantMetrics[] = testVariants.map((tv) => {
+ const key = normalizeUrl(tv.url);
+ const a = analyticsByNormalizedUrl.get(key);
+ return {
+ url: tv.url,
+ clicks: a?.clicks ?? 0,
+ leads: a?.leads ?? 0,
+ conversions: a?.sales ?? 0,
+ };
+ });
+
+ const safeRate = (num: number, den: number) => (den > 0 ? num / den : 0);
+
+ const maxConversions = Math.max(0, ...variants.map((v) => v.conversions));
+
+ let candidateUrls: string[] = [];
+
+ if (maxConversions > 0) {
+ // Primary path: conversions -> conversion rate -> clicks
+ const maxConv = maxConversions;
+ const convCandidates = variants.filter((v) => v.conversions === maxConv);
+ if (convCandidates.length === 1) {
+ candidateUrls = [convCandidates[0].url];
+ } else {
+ const maxConvRate = Math.max(
+ 0,
+ ...convCandidates.map((v) => safeRate(v.conversions, v.clicks)),
+ );
+ const rateCandidates = convCandidates.filter(
+ (v) => safeRate(v.conversions, v.clicks) === maxConvRate,
+ );
+ if (rateCandidates.length === 1) {
+ candidateUrls = [rateCandidates[0].url];
+ } else {
+ const maxClicks = Math.max(0, ...rateCandidates.map((v) => v.clicks));
+ const clickCandidates = rateCandidates.filter(
+ (v) => v.clicks === maxClicks,
+ );
+ if (clickCandidates.length === 1) {
+ candidateUrls = [clickCandidates[0].url];
+ } else {
+ // Still tied after all tie-breakers: do not pick randomly
+ candidateUrls = [];
+ }
+ }
+ }
+ } else {
+ // Fallback path: all variants have zero conversions
+ // leads -> lead rate -> clicks
+ const maxLeads = Math.max(0, ...variants.map((v) => v.leads));
+ const leadCandidates = variants.filter((v) => v.leads === maxLeads);
+ if (leadCandidates.length === 1) {
+ candidateUrls = [leadCandidates[0].url];
+ } else {
+ const maxLeadRate = Math.max(
+ 0,
+ ...leadCandidates.map((v) => safeRate(v.leads, v.clicks)),
+ );
+ const rateCandidates = leadCandidates.filter(
+ (v) => safeRate(v.leads, v.clicks) === maxLeadRate,
+ );
+ if (rateCandidates.length === 1) {
+ candidateUrls = [rateCandidates[0].url];
+ } else {
+ const maxClicks = Math.max(0, ...rateCandidates.map((v) => v.clicks));
+ const clickCandidates = rateCandidates.filter(
+ (v) => v.clicks === maxClicks,
+ );
+ if (clickCandidates.length === 1) {
+ candidateUrls = [clickCandidates[0].url];
+ } else {
+ // Still tied after all tie-breakers: do not pick randomly
+ candidateUrls = [];
+ }
+ }
+ }
+ }
- // this should NEVER happen, but just in case
- if (winners.length === 0) {
+ if (candidateUrls.length !== 1) {
console.log(
- `AB Test completed but failed to find winners based on max leads for link ${link.id}, doing nothing.`,
+ `AB Test completed for ${link.id} but no deterministic winner after tie-breakers. Keeping original destination URL.`,
);
return;
}
- const winner = winners[Math.floor(Math.random() * winners.length)];
+ const winner = { url: candidateUrls[0] };
if (winner.url === link.url) {
return;
diff --git a/apps/web/ui/links/destination-url-input.tsx b/apps/web/ui/links/destination-url-input.tsx
index ab427dc45d..f813684bf3 100644
--- a/apps/web/ui/links/destination-url-input.tsx
+++ b/apps/web/ui/links/destination-url-input.tsx
@@ -112,7 +112,20 @@ export const DestinationUrlInput = forwardRef<
const url = getUrlFromString(e.target.value);
if (url) {
// remove trailing slash and set the https:// prefix
- formContext.setValue("url", url.replace(/\/$/, ""));
+ const normalizedUrl = url.replace(/\/$/, "");
+ formContext.setValue("url", normalizedUrl, {
+ shouldDirty: true,
+ });
+
+ // If an A/B test exists, keep the first variant in sync
+ const testVariants = formContext.getValues("testVariants");
+ if (Array.isArray(testVariants) && testVariants.length > 0) {
+ formContext.setValue(
+ "testVariants.0.url" as any,
+ normalizedUrl,
+ { shouldDirty: true },
+ );
+ }
}
}}
/>
diff --git a/apps/web/ui/links/link-tests.tsx b/apps/web/ui/links/link-tests.tsx
index 7cfae99ea6..75cf32096a 100644
--- a/apps/web/ui/links/link-tests.tsx
+++ b/apps/web/ui/links/link-tests.tsx
@@ -1,6 +1,6 @@
import useWorkspace from "@/lib/swr/use-workspace";
import { ABTestVariantsSchema } from "@/lib/zod/schemas/links";
-import { fetcher } from "@dub/utils";
+import { fetcher, normalizeUrl } from "@dub/utils";
import { motion } from "motion/react";
import { memo, useMemo } from "react";
import useSWR from "swr";
@@ -54,6 +54,42 @@ export const LinkTests = memo(({ link }: { link: ResponseLink }) => {
},
);
+ const analyticsByNormalizedUrl = useMemo(() => {
+ if (!data) return null;
+
+ const map = new Map<
+ string,
+ {
+ clicks: number;
+ leads: number;
+ sales: number;
+ saleAmount: number;
+ }
+ >();
+
+ for (const row of data) {
+ const key = normalizeUrl(row.url);
+ const existing = map.get(key);
+ if (existing) {
+ map.set(key, {
+ clicks: existing.clicks + (row.clicks ?? 0),
+ leads: existing.leads + (row.leads ?? 0),
+ sales: existing.sales + (row.sales ?? 0),
+ saleAmount: existing.saleAmount + (row.saleAmount ?? 0),
+ });
+ } else {
+ map.set(key, {
+ clicks: row.clicks ?? 0,
+ leads: row.leads ?? 0,
+ sales: row.sales ?? 0,
+ saleAmount: row.saleAmount ?? 0,
+ });
+ }
+ }
+
+ return map;
+ }, [data]);
+
if (!testVariants || !testVariants.length) return null;
return (
@@ -65,7 +101,8 @@ export const LinkTests = memo(({ link }: { link: ResponseLink }) => {
>
{testVariants.map((test, idx) => {
- const analytics = data?.find(({ url }) => url === test.url);
+ const normalizedTestUrl = normalizeUrl(test.url);
+ const analytics = analyticsByNormalizedUrl?.get(normalizedTestUrl);
return (
{
-
- {/* Test percentage */}
-
-
- {Math.round(test.percentage)}%
-
-
-
+
{/* Analytics badge */}
{isLoading ? (
@@ -112,6 +142,13 @@ export const LinkTests = memo(({ link }: { link: ResponseLink }) => {
/>
)}
+
+ {/* Test percentage */}
+
+
+ {Math.round(test.percentage)}%
+
+
);
diff --git a/apps/web/ui/links/tests-badge.tsx b/apps/web/ui/links/tests-badge.tsx
index 5c09951493..b1fd7210a4 100644
--- a/apps/web/ui/links/tests-badge.tsx
+++ b/apps/web/ui/links/tests-badge.tsx
@@ -1,7 +1,7 @@
"use client";
import { Flask } from "@dub/ui/icons";
-import { cn } from "@dub/utils";
+import { cn, formatDateTime } from "@dub/utils";
import * as HoverCard from "@radix-ui/react-hover-card";
import { useLinkCardContext } from "./link-card";
import { ResponseLink } from "./links-container";
@@ -13,6 +13,17 @@ export function TestsBadge({
}) {
const { showTests, setShowTests } = useLinkCardContext();
+ const completedAtDate = link.testCompletedAt
+ ? new Date(link.testCompletedAt)
+ : null;
+ const formattedDate = completedAtDate
+ ? formatDateTime(completedAtDate, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : undefined;
+
return (
@@ -20,9 +31,23 @@ export function TestsBadge({
- A/B tests
+ {formattedDate ? (
+
+
+ A/B test is running
+
+
+ Scheduled completion date is{" "}
+
+ {formattedDate}
+
+
+
+ ) : (
+ A/B tests
+ )}
diff --git a/apps/web/ui/modals/link-builder/ab-testing-modal.tsx b/apps/web/ui/modals/link-builder/ab-testing-modal.tsx
index 5cc5cca0f4..dfd653af23 100644
--- a/apps/web/ui/modals/link-builder/ab-testing-modal.tsx
+++ b/apps/web/ui/modals/link-builder/ab-testing-modal.tsx
@@ -23,6 +23,7 @@ import {
cn,
formatDateTime,
getDateTimeLocal,
+ getUrlFromString,
isValidUrl,
parseDateTime,
} from "@dub/utils";
@@ -283,7 +284,7 @@ function ABTestingModal({
}
/>
@@ -295,49 +296,67 @@ function ABTestingModal({
className="-m-1"
>
- {testVariants.map((_, index) => (
-
-
-
- {index + 1}
-
- slug === domain)
- ?.placeholder ||
- "https://dub.co/help/article/what-is-dub"
- }
- className="block h-9 grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:ring-0 sm:text-sm"
- {...register(`testVariants.${index}.url`, {
- validate: (value, { testVariants }) => {
- if (!value) return "URL is required";
-
- if (!isValidUrl(value)) return "Invalid URL";
-
- return (
- testVariants.length > 1 &&
- testVariants.length <= MAX_TEST_COUNT
- );
- },
- })}
- />
- {index > 0 && (
- removeTestUrl(index)}
- variant="outline"
- className="mr-1 size-7 p-0"
- text={
- <>
- Remove
-
- >
+ {testVariants.map((_, index) => {
+ const field = register(`testVariants.${index}.url`, {
+ validate: (value, { testVariants }) => {
+ if (!value) return "URL is required";
+
+ if (!isValidUrl(value)) return "Invalid URL";
+
+ return (
+ testVariants.length > 1 &&
+ testVariants.length <= MAX_TEST_COUNT
+ );
+ },
+ });
+
+ return (
+
+
+
+ {index + 1}
+
+ slug === domain)
+ ?.placeholder ||
+ "https://dub.co/help/article/what-is-dub"
}
+ className="block h-9 grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:ring-0 sm:text-sm"
+ {...field}
+ onBlur={(e) => {
+ field.onBlur(e);
+ const url = getUrlFromString(e.target.value);
+ if (url) {
+ const normalizedUrl = url.replace(/\/$/, "");
+ setValue(
+ `testVariants.${index}.url`,
+ normalizedUrl,
+ {
+ shouldDirty: true,
+ },
+ );
+ }
+ }}
/>
- )}
-
-
- ))}
+ {index > 0 && (
+ removeTestUrl(index)}
+ variant="outline"
+ className="mr-1 size-7 p-0"
+ text={
+ <>
+ Remove
+
+ >
+ }
+ />
+ )}
+
+
+ );
+ })}
@@ -366,7 +385,7 @@ function ABTestingModal({
content={`Adjust the percentage of traffic to each URL. The minimum is ${MIN_TEST_PERCENTAGE}%`}
/>
-
+
{
@@ -394,7 +413,7 @@ function ABTestingModal({
}
/>
@@ -442,7 +461,17 @@ function ABTestingModal({
className="w-[40px] border-none bg-transparent text-neutral-500 focus:outline-none focus:ring-0 sm:text-sm"
/>
-
6 weeks maximum
+
6 * 7
+ ? "text-red-700"
+ : "text-neutral-500",
+ )}
+ >
+ 6 weeks maximum
+
{testVariantsParent && (
diff --git a/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx b/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx
index 8efffaae3d..ca799ef8f2 100644
--- a/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx
+++ b/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx
@@ -24,6 +24,7 @@ import {
cn,
formatDateTime,
getDateTimeLocal,
+ getUrlFromString,
isValidUrl,
parseDateTime,
} from "@dub/utils";
@@ -326,7 +327,7 @@ function ABTestingEdit({
}
/>
@@ -338,49 +339,67 @@ function ABTestingEdit({
className="-m-1"
>
- {testVariants.map((_, index) => (
-
-
-
- {index + 1}
-
- slug === domain)
- ?.placeholder ||
- "https://dub.co/help/article/what-is-dub"
- }
- className="block h-9 grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:ring-0 sm:text-sm"
- {...register(`testVariants.${index}.url`, {
- validate: (value, { testVariants }) => {
- if (!value) return "URL is required";
-
- if (!isValidUrl(value)) return "Invalid URL";
-
- return (
- testVariants.length > 1 &&
- testVariants.length <= MAX_TEST_COUNT
- );
- },
- })}
- />
- {index > 0 && (
- removeTestUrl(index)}
- variant="outline"
- className="mr-1 size-7 p-0"
- text={
- <>
- Remove
-
- >
+ {testVariants.map((_, index) => {
+ const field = register(`testVariants.${index}.url`, {
+ validate: (value, { testVariants }) => {
+ if (!value) return "URL is required";
+
+ if (!isValidUrl(value)) return "Invalid URL";
+
+ return (
+ testVariants.length > 1 &&
+ testVariants.length <= MAX_TEST_COUNT
+ );
+ },
+ });
+
+ return (
+
+
+
+ {index + 1}
+
+ slug === domain)
+ ?.placeholder ||
+ "https://dub.co/help/article/what-is-dub"
}
+ className="block h-9 grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:ring-0 sm:text-sm"
+ {...field}
+ onBlur={(e) => {
+ field.onBlur(e);
+ const url = getUrlFromString(e.target.value);
+ if (url) {
+ const normalizedUrl = url.replace(/\/$/, "");
+ setValue(
+ `testVariants.${index}.url`,
+ normalizedUrl,
+ {
+ shouldDirty: true,
+ },
+ );
+ }
+ }}
/>
- )}
-
-
- ))}
+ {index > 0 && (
+ removeTestUrl(index)}
+ variant="outline"
+ className="mr-1 size-7 p-0"
+ text={
+ <>
+ Remove
+
+ >
+ }
+ />
+ )}
+
+
+ );
+ })}
@@ -409,7 +428,7 @@ function ABTestingEdit({
content={`Adjust the percentage of traffic to each URL. The minimum is ${MIN_TEST_PERCENTAGE}%`}
/>
-
+
{
@@ -435,9 +454,9 @@ function ABTestingEdit({
}
/>
@@ -485,7 +504,17 @@ function ABTestingEdit({
className="w-[40px] border-none bg-transparent text-neutral-500 focus:outline-none focus:ring-0 sm:text-sm"
/>
-
6 weeks maximum
+
6 * 7
+ ? "text-red-700"
+ : "text-neutral-500",
+ )}
+ >
+ 6 weeks maximum
+
{testVariantsParent && (
diff --git a/apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx b/apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx
index 1f40a66e0c..173a8e2d78 100644
--- a/apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx
+++ b/apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx
@@ -1,13 +1,52 @@
+import useWorkspace from "@/lib/swr/use-workspace";
+import { LinkAnalyticsBadge } from "@/ui/links/link-analytics-badge";
import { LinkFormData } from "@/ui/links/link-builder/link-builder-provider";
-import { Button, Modal } from "@dub/ui";
+import { Button, Modal, Tooltip } from "@dub/ui";
+import { fetcher, normalizeUrl } from "@dub/utils";
import {
Dispatch,
SetStateAction,
useCallback,
+ useEffect,
useMemo,
+ useRef,
useState,
} from "react";
import { useFormContext } from "react-hook-form";
+import useSWR from "swr";
+
+function UrlWithTooltip({ url }: { url: string }) {
+ const textRef = useRef(null);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+
+ useEffect(() => {
+ const element = textRef.current;
+ if (element) {
+ setIsOverflowing(element.scrollWidth > element.clientWidth);
+ }
+ }, [url]);
+
+ const content = (
+
+ {url}
+
+ );
+
+ if (!isOverflowing) return content;
+
+ return (
+ {url}
+ }
+ >
+ {content}
+
+ );
+}
function EndABTestingModal({
showEndABTestingModal,
@@ -18,8 +57,12 @@ function EndABTestingModal({
setShowEndABTestingModal: Dispatch>;
onEndTest?: () => void;
}) {
- const { watch: watchParent, setValue: setValueParent } =
- useFormContext();
+ const { id: workspaceId } = useWorkspace();
+ const {
+ watch: watchParent,
+ setValue: setValueParent,
+ getValues: getValuesParent,
+ } = useFormContext();
const testVariants = watchParent("testVariants") as Array<{
url: string;
@@ -28,6 +71,59 @@ function EndABTestingModal({
const [selectedUrl, setSelectedUrl] = useState(null);
+ const [linkId, testStartedAt] = watchParent(["id", "testStartedAt"]);
+
+ const { data, error, isLoading } = useSWR<
+ {
+ url: string;
+ clicks: number;
+ leads: number;
+ saleAmount: number;
+ sales: number;
+ }[]
+ >(
+ Boolean(testVariants && testVariants.length && linkId && workspaceId) &&
+ `/api/analytics?${new URLSearchParams({
+ event: "composite",
+ groupBy: "top_urls",
+ linkId: linkId as string,
+ workspaceId: workspaceId!,
+ ...(testStartedAt && {
+ start: new Date(testStartedAt as Date).toISOString(),
+ }),
+ }).toString()}`,
+ fetcher,
+ { revalidateOnFocus: false },
+ );
+
+ const analyticsByNormalizedUrl = useMemo(() => {
+ if (!data) return null;
+ const map = new Map<
+ string,
+ { clicks: number; leads: number; sales: number; saleAmount: number }
+ >();
+ for (const row of data) {
+ const key = normalizeUrl(row.url);
+ const existing = map.get(key);
+ if (existing) {
+ map.set(key, {
+ clicks: existing.clicks + (row.clicks ?? 0),
+ leads: existing.leads + (row.leads ?? 0),
+ sales: existing.sales + (row.sales ?? 0),
+ saleAmount: existing.saleAmount + (row.saleAmount ?? 0),
+ });
+ } else {
+ map.set(key, {
+ clicks: row.clicks ?? 0,
+ leads: row.leads ?? 0,
+ sales: row.sales ?? 0,
+ saleAmount: row.saleAmount ?? 0,
+ });
+ }
+ }
+ return map;
+ }, [data]);
+
return (
End A/B test
-
+
- Select which destination URL to use as the current destination URL,
- and end the test. Save your changes on the link editor to confirm
- the change.
+ Select the new destination URL to end the test. Save your changes on
+ the link editor to confirm the change.
- {testVariants?.map((test, index) => (
-
setSelectedUrl(test.url)}
- className={`relative flex w-full items-center rounded-md border bg-white p-0 text-left ring-0 ring-black transition-all duration-100 hover:bg-neutral-50 ${
- selectedUrl === test.url
- ? "border-black ring-1"
- : "border-neutral-300"
- }`}
- >
-
-
- {index + 1}
-
-
- {test.url}
-
-
-
-
- ))}
+ {testVariants?.map((test, index) => {
+ const normalized = normalizeUrl(test.url);
+ const analytics = analyticsByNormalizedUrl?.get(normalized);
+ const link = getValuesParent();
+ return (
+
setSelectedUrl(test.url)}
+ className={`relative flex w-full items-center justify-between rounded-lg border bg-white p-0 text-left ring-0 ring-black transition-all duration-100 hover:bg-neutral-50 ${
+ selectedUrl === test.url
+ ? "border-black ring-1"
+ : "border-neutral-200"
+ }`}
+ >
+
+
+ {isLoading || !analyticsByNormalizedUrl ? (
+
+ ) : error ? null : (
+
+ )}
+
+
+ );
+ })}