Skip to content

Commit 2ec0ffd

Browse files
committed
feat: implement A/B testing for link placements (#687)
1 parent 2b9548d commit 2ec0ffd

10 files changed

Lines changed: 754 additions & 190 deletions

File tree

app/[username]/page.tsx

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ import { ProfileCard } from "./ProfileCard";
77
import { ProfileFooter } from "./ProfileFooter";
88
import { resolveUserByUsername } from "@/lib/userLookup";
99
import { ShareProfileButton } from "./ShareProfileButton";
10+
import { cookies } from "next/headers";
11+
import type { Link } from "./types/type";
12+
13+
interface ABTestSlot {
14+
__abTestSlot: string;
15+
}
16+
17+
function getDeterministicVariant(visitorId: string, parentId: string): "A" | "B" {
18+
let hash = 0;
19+
const str = visitorId + parentId;
20+
for (let i = 0; i < str.length; i++) {
21+
hash = (hash << 5) - hash + str.charCodeAt(i);
22+
hash |= 0;
23+
}
24+
return Math.abs(hash) % 2 === 0 ? "A" : "B";
25+
}
1026

1127
export async function generateMetadata({ params }: { params: Promise<{ username: string }> }) {
1228
try {
@@ -118,12 +134,96 @@ export default async function PublicProfile({
118134
bgStyle.backgroundImage = "linear-gradient(180deg, #09090b 0%, #1e1b4b 100%)";
119135
}
120136

137+
const cookieStore = await cookies();
138+
121139
const now = new Date();
122-
const activeLinks = (user.links || []).filter((link: { startDate?: Date | null; endDate?: Date | null }) => {
123-
if (link.startDate && new Date(link.startDate) > now) return false;
124-
if (link.endDate && new Date(link.endDate) < now) return false;
140+
const isActive = (l: Link) => {
141+
if (l.startDate && new Date(l.startDate) > now) return false;
142+
if (l.endDate && new Date(l.endDate) < now) return false;
125143
return true;
126-
});
144+
};
145+
146+
const selectVariant = (parentId: string, variants: Link[], visitorId: string): Link => {
147+
const cookieVal = cookieStore.get(`abTest_${parentId}`)?.value;
148+
if (cookieVal === "A" || cookieVal === "B") {
149+
const picked = variants.find((v) => v.abTestVariant === cookieVal);
150+
if (picked) return picked;
151+
}
152+
const chosenVariant = getDeterministicVariant(visitorId, parentId);
153+
return variants.find((v) => v.abTestVariant === chosenVariant) || variants[0];
154+
};
155+
156+
const visitorId = cookieStore.get("visitor_id")?.value || "default-visitor";
157+
158+
const rawLinks = (user.links || []) as Link[];
159+
const abTestGroups = new Map<string, Link[]>();
160+
const preFilteredLinks: (Link | ABTestSlot)[] = [];
161+
162+
for (const link of rawLinks) {
163+
if (link.abTestParentId) {
164+
if (!abTestGroups.has(link.abTestParentId)) {
165+
abTestGroups.set(link.abTestParentId, []);
166+
preFilteredLinks.push({ __abTestSlot: link.abTestParentId });
167+
}
168+
abTestGroups.get(link.abTestParentId)!.push(link);
169+
} else if (link.isGroup) {
170+
const children = (link.children || []) as Link[];
171+
const newChildren: (Link | ABTestSlot)[] = [];
172+
const childrenGroups = new Map<string, Link[]>();
173+
174+
for (const child of children) {
175+
if (child.abTestParentId) {
176+
if (!childrenGroups.has(child.abTestParentId)) {
177+
childrenGroups.set(child.abTestParentId, []);
178+
newChildren.push({ __abTestSlot: child.abTestParentId });
179+
}
180+
childrenGroups.get(child.abTestParentId)!.push(child);
181+
} else {
182+
newChildren.push(child);
183+
}
184+
}
185+
186+
for (const [parentId, variants] of childrenGroups.entries()) {
187+
const activeVariants = variants.filter(isActive);
188+
const slot = newChildren.findIndex((l) => "__abTestSlot" in l && (l as any).__abTestSlot === parentId);
189+
if (activeVariants.length === 0) {
190+
if (slot !== -1) {
191+
newChildren.splice(slot, 1);
192+
}
193+
} else {
194+
const picked = selectVariant(parentId, activeVariants, visitorId);
195+
if (slot !== -1) {
196+
newChildren.splice(slot, 1, picked);
197+
} else {
198+
newChildren.push(picked);
199+
}
200+
}
201+
}
202+
203+
preFilteredLinks.push({ ...link, children: newChildren as Link[] });
204+
} else {
205+
preFilteredLinks.push(link);
206+
}
207+
}
208+
209+
for (const [parentId, variants] of abTestGroups.entries()) {
210+
const activeVariants = variants.filter(isActive);
211+
const slot = preFilteredLinks.findIndex((l) => "__abTestSlot" in l && (l as any).__abTestSlot === parentId);
212+
if (activeVariants.length === 0) {
213+
if (slot !== -1) {
214+
preFilteredLinks.splice(slot, 1);
215+
}
216+
} else {
217+
const picked = selectVariant(parentId, activeVariants, visitorId);
218+
if (slot !== -1) {
219+
preFilteredLinks.splice(slot, 1, picked);
220+
} else {
221+
preFilteredLinks.push(picked);
222+
}
223+
}
224+
}
225+
226+
const activeLinks = (preFilteredLinks as Link[]).filter(isActive);
127227

128228
return (
129229
<main className={`min-h-screen relative px-4 py-16 theme-${user.theme || "default"}`}>

app/[username]/types/type.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export type Link = {
1616
startDate?: Date | null;
1717
endDate?: Date | null;
1818
updatedAt?: Date;
19-
userId: string;
19+
abTestVariant?: "A" | "B" | null;
20+
abTestParentId?: string | null;
2021
}
2122

2223
export type PlatformParams = {

app/api/links/[id]/route.ts

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { NextResponse } from "next/server";
22
import { getServerSession } from "next-auth";
33
import { authOptions } from "@/lib/auth";
44
import prisma from "@/lib/prisma";
5+
6+
const getMembership = async (userId: string, workspaceId: string) => {
7+
return await prisma.workspaceMember.findFirst({
8+
where: { userId, workspaceId },
9+
});
10+
};
11+
12+
13+
14+
515
import { Prisma } from "@prisma/client";
616

717
import { validatePlatformUrl, detectPlatform, slugifyPlatform, isKnownPlatform, type Platform } from "@/lib/platforms";
@@ -53,12 +63,25 @@ export async function PUT(
5363
? rawExplicitPlatform as Platform
5464
: null;
5565

66+
const user = await prisma.user.findUnique({
67+
where: { email: session.user.email },
68+
select: { id: true },
69+
});
70+
71+
if (!user) {
72+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
73+
}
74+
5675
const link = await prisma.link.findUnique({
5776
where: { id },
58-
include: { user: true },
5977
});
6078

61-
if (!link || link.user.email !== session.user.email) {
79+
if (!link) {
80+
return NextResponse.json({ error: "Not Found" }, { status: 404 });
81+
}
82+
83+
const membership = await getMembership(user.id, link.workspaceId);
84+
if (!membership) {
6285
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
6386
}
6487

@@ -76,7 +99,7 @@ export async function PUT(
7699
data.parentId = null;
77100
} else {
78101
const parentGroup = await prisma.link.findFirst({
79-
where: { id: parentId, userId: link.userId, isGroup: true },
102+
where: { id: parentId, workspaceId: link.workspaceId, isGroup: true },
80103
});
81104
if (!parentGroup) {
82105
return NextResponse.json(
@@ -197,7 +220,7 @@ export async function PUT(
197220
const proposedRoute = link.alias || data.platform;
198221
const existingLink = await tx.link.findFirst({
199222
where: {
200-
userId: link.userId,
223+
workspaceId: link.workspaceId,
201224
id: { not: link.id },
202225
isGroup: false,
203226
OR: [
@@ -277,12 +300,25 @@ export async function DELETE(
277300
// No body is fine for regular link deletion
278301
}
279302

303+
const user = await prisma.user.findUnique({
304+
where: { email: session.user.email },
305+
select: { id: true },
306+
});
307+
308+
if (!user) {
309+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
310+
}
311+
280312
const link = await prisma.link.findUnique({
281313
where: { id },
282-
include: { user: true },
283314
});
284315

285-
if (!link || link.user.email !== session.user.email) {
316+
if (!link) {
317+
return NextResponse.json({ error: "Not Found" }, { status: 404 });
318+
}
319+
320+
const membership = await getMembership(user.id, link.workspaceId);
321+
if (!membership) {
286322
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
287323
}
288324

@@ -292,17 +328,17 @@ export async function DELETE(
292328
if (deleteChildren) {
293329
// Delete all children first, then the group
294330
await tx.link.deleteMany({
295-
where: { parentId: id, userId: link.userId },
331+
where: { parentId: id, workspaceId: link.workspaceId },
296332
});
297333
} else {
298334
// Ungroup: set children's parentId to null and reassign positions
299335
const children = await tx.link.findMany({
300-
where: { parentId: id, userId: link.userId },
336+
where: { parentId: id, workspaceId: link.workspaceId },
301337
orderBy: { position: 'asc' },
302338
});
303339

304340
const maxOrder = await tx.link.aggregate({
305-
where: { userId: link.userId, parentId: null },
341+
where: { workspaceId: link.workspaceId, parentId: null },
306342
_max: { position: true },
307343
});
308344

@@ -321,13 +357,47 @@ export async function DELETE(
321357
return NextResponse.json({ success: true });
322358
}
323359

360+
// A/B test variant reversion logic
361+
if (link.abTestParentId) {
362+
const parentId = link.abTestParentId;
363+
await prisma.$transaction(async (tx) => {
364+
// Find the sibling variant
365+
const sibling = await tx.link.findFirst({
366+
where: {
367+
abTestParentId: parentId,
368+
id: { not: id },
369+
},
370+
});
371+
372+
if (sibling) {
373+
// Revert sibling to a standard link
374+
let cleanPlatform = sibling.platform;
375+
if (cleanPlatform.endsWith("__ab_b")) {
376+
cleanPlatform = cleanPlatform.replace(/__ab_b$/, "");
377+
}
378+
await tx.link.update({
379+
where: { id: sibling.id },
380+
data: {
381+
abTestVariant: null,
382+
abTestParentId: null,
383+
platform: cleanPlatform,
384+
},
385+
});
386+
}
387+
388+
// Delete the requested link
389+
await tx.link.delete({
390+
where: { id },
391+
});
392+
});
393+
394+
return NextResponse.json({ success: true });
395+
}
396+
324397
// Regular link deletion
325398
await prisma.link.delete({
326399
where: { id },
327400
});
328401

329402
return NextResponse.json({ success: true });
330403
}
331-
332-
333-

0 commit comments

Comments
 (0)