Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
108 changes: 104 additions & 4 deletions app/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { Toaster } from "react-hot-toast";
import prisma from "@/lib/prisma";

Check warning on line 4 in app/[username]/page.tsx

View workflow job for this annotation

GitHub Actions / ci

'prisma' is defined but never used
import { authOptions } from "@/lib/auth";
import { ProfileCard } from "./ProfileCard";
import { ProfileFooter } from "./ProfileFooter";
import { resolveUserByUsername } from "@/lib/userLookup";
import { ShareProfileButton } from "./ShareProfileButton";
import { cookies } from "next/headers";
import type { Link } from "./types/type";

interface ABTestSlot {
__abTestSlot: string;
}

function getDeterministicVariant(visitorId: string, parentId: string): "A" | "B" {
let hash = 0;
const str = visitorId + parentId;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 2 === 0 ? "A" : "B";
}

export async function generateMetadata({ params }: { params: Promise<{ username: string }> }) {
try {
Expand Down Expand Up @@ -114,12 +130,96 @@
bgStyle.backgroundImage = "linear-gradient(180deg, #09090b 0%, #1e1b4b 100%)";
}

const cookieStore = await cookies();

const now = new Date();
const activeLinks = (user.links || []).filter((link: { startDate?: Date | null; endDate?: Date | null }) => {
if (link.startDate && new Date(link.startDate) > now) return false;
if (link.endDate && new Date(link.endDate) < now) return false;
const isActive = (l: Link) => {
if (l.startDate && new Date(l.startDate) > now) return false;
if (l.endDate && new Date(l.endDate) < now) return false;
return true;
});
};

const selectVariant = (parentId: string, variants: Link[], visitorId: string): Link => {
const cookieVal = cookieStore.get(`abTest_${parentId}`)?.value;
if (cookieVal === "A" || cookieVal === "B") {
const picked = variants.find((v) => v.abTestVariant === cookieVal);
if (picked) return picked;
}
const chosenVariant = getDeterministicVariant(visitorId, parentId);
return variants.find((v) => v.abTestVariant === chosenVariant) || variants[0];
};

const visitorId = cookieStore.get("visitor_id")?.value || "default-visitor";

const rawLinks = (user.links || []) as Link[];
const abTestGroups = new Map<string, Link[]>();
const preFilteredLinks: (Link | ABTestSlot)[] = [];

for (const link of rawLinks) {
if (link.abTestParentId) {
if (!abTestGroups.has(link.abTestParentId)) {
abTestGroups.set(link.abTestParentId, []);
preFilteredLinks.push({ __abTestSlot: link.abTestParentId });
}
abTestGroups.get(link.abTestParentId)!.push(link);
} else if (link.isGroup) {
const children = (link.children || []) as Link[];
const newChildren: (Link | ABTestSlot)[] = [];
const childrenGroups = new Map<string, Link[]>();

for (const child of children) {
if (child.abTestParentId) {
if (!childrenGroups.has(child.abTestParentId)) {
childrenGroups.set(child.abTestParentId, []);
newChildren.push({ __abTestSlot: child.abTestParentId });
}
childrenGroups.get(child.abTestParentId)!.push(child);
} else {
newChildren.push(child);
}
}

for (const [parentId, variants] of childrenGroups.entries()) {
const activeVariants = variants.filter(isActive);
const slot = newChildren.findIndex((l) => "__abTestSlot" in l && (l as any).__abTestSlot === parentId);

Check failure on line 184 in app/[username]/page.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (activeVariants.length === 0) {
if (slot !== -1) {
newChildren.splice(slot, 1);
}
} else {
const picked = selectVariant(parentId, activeVariants, visitorId);
if (slot !== -1) {
newChildren.splice(slot, 1, picked);
} else {
newChildren.push(picked);
}
}
}

preFilteredLinks.push({ ...link, children: newChildren as Link[] });
} else {
preFilteredLinks.push(link);
}
}

for (const [parentId, variants] of abTestGroups.entries()) {
const activeVariants = variants.filter(isActive);
const slot = preFilteredLinks.findIndex((l) => "__abTestSlot" in l && (l as any).__abTestSlot === parentId);

Check failure on line 207 in app/[username]/page.tsx

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
if (activeVariants.length === 0) {
if (slot !== -1) {
preFilteredLinks.splice(slot, 1);
}
} else {
const picked = selectVariant(parentId, activeVariants, visitorId);
if (slot !== -1) {
preFilteredLinks.splice(slot, 1, picked);
} else {
preFilteredLinks.push(picked);
}
}
}

const activeLinks = (preFilteredLinks as Link[]).filter(isActive);

return (
<main className={`min-h-screen relative px-4 py-16 theme-${user.theme || "default"}`}>
Expand Down
3 changes: 2 additions & 1 deletion app/[username]/types/type.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type Link = {
startDate?: Date | null;
endDate?: Date | null;
updatedAt?: Date;
workspaceId: string;
abTestVariant?: "A" | "B" | null;
abTestParentId?: string | null;
}

export type PlatformParams = {
Expand Down
46 changes: 43 additions & 3 deletions app/api/links/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import prisma from "@/lib/prisma";




import { Prisma } from "@prisma/client";
import { resolveActiveWorkspace } from "@/lib/workspace";

Expand Down Expand Up @@ -295,6 +299,8 @@ export async function DELETE(
// No body is fine for regular link deletion
}



// Group deletion with transaction
if (link.isGroup) {
await prisma.$transaction(async (tx) => {
Expand Down Expand Up @@ -333,6 +339,43 @@ export async function DELETE(
return NextResponse.json({ success: true });
}

// A/B test variant reversion logic
if (link.abTestParentId) {
const parentId = link.abTestParentId;
await prisma.$transaction(async (tx) => {
// Find the sibling variant
const sibling = await tx.link.findFirst({
where: {
abTestParentId: parentId,
id: { not: id },
},
});

if (sibling) {
// Revert sibling to a standard link
let cleanPlatform = sibling.platform;
if (cleanPlatform.endsWith("__ab_b")) {
cleanPlatform = cleanPlatform.replace(/__ab_b$/, "");
}
await tx.link.update({
where: { id: sibling.id },
data: {
abTestVariant: null,
abTestParentId: null,
platform: cleanPlatform,
},
});
}

// Delete the requested link
await tx.link.delete({
where: { id },
});
});

return NextResponse.json({ success: true });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Regular link deletion
await prisma.link.delete({
where: { id },
Expand All @@ -343,6 +386,3 @@ export async function DELETE(

return NextResponse.json({ success: true });
}



138 changes: 138 additions & 0 deletions app/api/links/ab-test/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { NextResponse, NextRequest } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import prisma from "@/lib/prisma";

class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}

export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { linkId } = await req.json();

if (!linkId) {
return NextResponse.json({ error: "Missing linkId" }, { status: 400 });
}

const user = await prisma.user.findUnique({
where: { email: session.user.email },
});

if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
Comment thread
vishnukothakapu marked this conversation as resolved.

try {
const result = await prisma.$transaction(async (tx) => {
// Atomically claim the link by setting abTestVariant = "A" and abTestParentId = id
// only if abTestParentId is currently null.
const updateCount = await tx.link.updateMany({
where: {
id: linkId,
userId: user.id,
abTestParentId: null,
},
data: {
abTestVariant: "A",
abTestParentId: linkId,
},
});

if (updateCount.count !== 1) {
const existing = await tx.link.findUnique({
where: { id: linkId },
});
if (!existing) {
throw new ApiError("Link not found", 404);
}
if (existing.userId !== user.id) {
throw new ApiError("Unauthorized", 403);
}
if (existing.abTestParentId) {
throw new ApiError("Link is already part of an A/B test", 409);
}
throw new ApiError("Failed to initialize A/B test", 400);
}

const originalLink = await tx.link.findUnique({
where: { id: linkId },
});

if (!originalLink) {
throw new ApiError("Link not found", 404);
}

// Shift subsequent links' positions to avoid position duplication
await tx.link.updateMany({
where: {
userId: user.id,
position: { gte: originalLink.position + 1 },
},
data: {
position: { increment: 1 },
},
});

// Create variant B directly after original link
const newLink = await tx.link.create({
data: {
workspaceId: originalLink.workspaceId,
userId: user.id,
platform: `${originalLink.platform}__ab_b`,
alias: originalLink.alias ? `${originalLink.alias}-b` : null,
label: `${originalLink.label} (Variant B)`,
url: originalLink.url,
position: originalLink.position + 1,
isPublic: originalLink.isPublic,
isGroup: originalLink.isGroup,
parentId: originalLink.parentId,
pinCode: originalLink.pinCode,
isSocialIcon: originalLink.isSocialIcon,
startDate: originalLink.startDate,
endDate: originalLink.endDate,
abTestVariant: "B",
abTestParentId: originalLink.id,
},
});

return { variantA: originalLink, variantB: newLink };
});

return NextResponse.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
return NextResponse.json(
{ error: err.message },
{ status: err.status }
);
}

if (err instanceof Error) {
if (err.message === "Link not found") {
return NextResponse.json({ error: err.message }, { status: 404 });
}
if (err.message === "Unauthorized") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.message.includes("already part of")) {
return NextResponse.json({ error: err.message }, { status: 409 });
}
}

console.error("A/B test creation error:", err);
return NextResponse.json(
{ error: "Failed to create A/B test" },
{ status: 500 }
);
}
}

Loading
Loading