Skip to content
Open
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
115 changes: 110 additions & 5 deletions app/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
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, headers } 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;
}
// Thomas Wang's 32-bit mix
hash = ((hash >> 16) ^ hash) * 0x45d9f3b;
hash = ((hash >> 16) ^ hash) * 0x45d9f3b;
hash = (hash >> 16) ^ hash;
return ((hash >> 16) & 1) === 0 ? "A" : "B";
}

export async function generateMetadata({ params }: { params: Promise<{ username: string }> }) {
try {
Expand Down Expand Up @@ -90,7 +110,7 @@
if (session?.user?.id) {
const { getWorkspaceMembership } = await import("@/lib/workspace");
const role = await getWorkspaceMembership(session.user.id, user.id);
isOwner = role !== null;
isOwner = role === "OWNER";
}

const bgStyle: React.CSSProperties = {};
Expand All @@ -114,12 +134,97 @@
bgStyle.backgroundImage = "linear-gradient(180deg, #09090b 0%, #1e1b4b 100%)";
}

const cookieStore = await cookies();
const headersList = await headers();

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 = headersList.get("x-visitor-id")!;

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.__abTestSlot === parentId);
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.__abTestSlot === parentId);
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
54 changes: 51 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 { triggerPusherEvent } from "@/lib/pusher";
import { resolveActiveWorkspace } from "@/lib/workspace";
Expand Down Expand Up @@ -298,10 +302,17 @@ export async function DELETE(
// No body is fine for regular link deletion
}



// Group deletion with transaction
if (link.isGroup) {
await prisma.$transaction(async (tx) => {
if (deleteChildren) {
// Clear all abTestParentId relations for the group's children first to satisfy NoAction
await tx.link.updateMany({
where: { parentId: id, workspaceId: link.workspaceId },
data: { abTestParentId: null },
});
// Delete all children first, then the group
await tx.link.deleteMany({
where: { parentId: id, workspaceId: link.workspaceId },
Expand Down Expand Up @@ -338,6 +349,46 @@ 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 },
});
});

// Deleted links disappear from the public profile — purge the cache.
await invalidateProfileCache(link.workspaceId);

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

// Regular link deletion
await prisma.link.delete({
where: { id },
Expand All @@ -350,6 +401,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 });
}

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