Interactive Like Feature (Phase 2) Implementation Plan
Interactive Like Feature (Phase 2) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add interactive site-level likes with like/unlike, backed by Prisma and TRPC, plus an unauthenticated login modal flow.
Architecture: Add a Like table keyed by siteId + userId. A TRPC router exposes getStatus (public) and toggle (protected). A client LikeRow uses TRPC hooks and NextAuth session, opens a modal with reused login page content for unauthenticated users, and completes pending likes after login.
Tech Stack: Next.js App Router, React, tRPC, Prisma, NextAuth, Jest, Testing Library.
Task 1: Add failing TRPC like router tests
Files:
- Create:
server/api/routers/__tests__/like.test.ts
Step 1: Write the failing test
import { appRouter } from "@/server/api/root";
type LikeRecord = { id: string; siteId: string; userId: string };
describe("like router", () => {
const createFakeDb = (likes: LikeRecord[]) => {
return {
like: {
count: async ({ where }: { where: { siteId: string } }) =>
likes.filter((like) => like.siteId === where.siteId).length,
findUnique: async ({
where,
}: {
where: { siteId_userId: { siteId: string; userId: string } };
}) =>
likes.find(
(like) =>
like.siteId === where.siteId_userId.siteId &&
like.userId === where.siteId_userId.userId,
) ?? null,
create: async ({
data,
}: {
data: { siteId: string; userId: string };
}) => {
const record = {
id: `like-${likes.length + 1}`,
siteId: data.siteId,
userId: data.userId,
};
likes.push(record);
return record;
},
delete: async ({ where }: { where: { id: string } }) => {
const index = likes.findIndex((like) => like.id === where.id);
if (index >= 0) {
likes.splice(index, 1);
}
},
},
};
};
test("returns count and liked state for anonymous viewer", async () => {
const likes: LikeRecord[] = [];
const ctx = {
db: createFakeDb(likes),
session: null,
} as any;
const caller = (appRouter as any).createCaller(ctx);
const result = await caller.like.getStatus({ siteId: "site-1" });
expect(result).toEqual({ count: 0, likedByViewer: false });
});
test("toggles like for authenticated viewer", async () => {
const likes: LikeRecord[] = [];
const ctx = {
db: createFakeDb(likes),
session: { user: { id: "user-1" } },
} as any;
const caller = (appRouter as any).createCaller(ctx);
const liked = await caller.like.toggle({ siteId: "site-1" });
expect(liked).toEqual({ count: 1, likedByViewer: true });
const unliked = await caller.like.toggle({ siteId: "site-1" });
expect(unliked).toEqual({ count: 0, likedByViewer: false });
});
});
Step 2: Run test to verify it fails
Run: pnpm test -- server/api/routers/__tests__/like.test.ts
Expected: FAIL because like router is not yet available on appRouter.
Step 3: Write minimal implementation
Create server/api/routers/like.ts:
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc";
export const likeRouter = createTRPCRouter({
getStatus: publicProcedure
.input(z.object({ siteId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const count = await ctx.db.like.count({ where: { siteId: input.siteId } });
if (!ctx.session?.user?.id) {
return { count, likedByViewer: false };
}
const existing = await ctx.db.like.findUnique({
where: {
siteId_userId: {
siteId: input.siteId,
userId: ctx.session.user.id,
},
},
});
return { count, likedByViewer: Boolean(existing) };
}),
toggle: protectedProcedure
.input(z.object({ siteId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const existing = await ctx.db.like.findUnique({
where: {
siteId_userId: {
siteId: input.siteId,
userId,
},
},
});
if (existing) {
await ctx.db.like.delete({ where: { id: existing.id } });
} else {
await ctx.db.like.create({ data: { siteId: input.siteId, userId } });
}
const count = await ctx.db.like.count({ where: { siteId: input.siteId } });
return { count, likedByViewer: !existing };
}),
});
Update server/api/root.ts:
import { likeRouter } from "@/server/api/routers/like";
export const appRouter = createTRPCRouter({
user: userRouter,
site: siteRouter,
home: homeRouter,
stripe: stripeRouter,
like: likeRouter,
});
Step 4: Run test to verify it passes
Run: pnpm test -- server/api/routers/__tests__/like.test.ts
Expected: PASS
Step 5: Commit
git add server/api/routers/like.ts server/api/root.ts server/api/routers/__tests__/like.test.ts
git commit -m "feat: add like router"
Task 2: Add Like model to Prisma schema
Files:
- Modify:
prisma/schema.prisma
Step 1: Update schema
model User {
id String @id @default(cuid())
name String?
username String?
gh_username String?
email String? @unique
emailVerified DateTime?
image String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
sites Site[]
likes Like[]
}
model Site {
id String @id @default(cuid())
gh_repository String
gh_branch String
subdomain String? @unique
customDomain String? @unique
rootDir String?
projectName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String?
syncStatus Status @default(SUCCESS)
syncError Json?
syncedAt DateTime?
autoSync Boolean @default(false)
webhookId String? @unique
files Json? @db.JsonB
enableComments Boolean @default(false)
giscusRepoId String?
giscusCategoryId String?
plan Plan @default(FREE)
subscription Subscription?
likes Like[]
@@unique([userId, projectName])
@@index([userId])
}
model Like {
id String @id @default(cuid())
siteId String
userId String
createdAt DateTime @default(now())
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([siteId, userId])
@@index([siteId])
@@index([userId])
}
Step 2: Generate Prisma client (and migrate as needed)
Run: pnpm prisma generate
If migrations are in use, run: pnpm prisma migrate dev --name add-like
Step 3: Commit
git add prisma/schema.prisma
# add prisma/migrations if generated
git commit -m "feat: add likes model"
Task 3: Extract reusable login panel (test-first)
Files:
- Create:
components/__tests__/login-panel.test.tsx - Create:
components/auth/login-panel.tsx - Modify:
app/cloud/(auth)/login/page.tsx
Step 1: Write the failing test
/**
* @jest-environment jsdom
*/
import { render, screen } from "@testing-library/react";
import LoginPanel from "@/components/auth/login-panel";
jest.mock("next/image", () => (props: any) => <img {...props} />);
jest.mock("@/lib/app-config", () => ({
getConfig: () => ({
title: "DataHub",
logo: "/logo.png",
termsOfService: "https://example.com/terms",
}),
}));
jest.mock("@/app/cloud/(auth)/login/login-button", () => () => (
<button>Login with GitHub</button>
));
test("renders login panel content", () => {
render(<LoginPanel />);
expect(screen.getByText("DataHub")).toBeInTheDocument();
expect(screen.getByText("Login with GitHub")).toBeInTheDocument();
});
Step 2: Run test to verify it fails
Run: pnpm test -- components/__tests__/login-panel.test.tsx
Expected: FAIL because LoginPanel does not exist.
Step 3: Write minimal implementation
Create components/auth/login-panel.tsx:
"use client";
import Image from "next/image";
import { Suspense } from "react";
import LoginButton from "@/app/cloud/(auth)/login/login-button";
import { getConfig } from "@/lib/app-config";
const config = getConfig();
export default function LoginPanel() {
return (
<div className="mx-5 border border-primary-faint p-10 sm:mx-auto sm:w-full sm:max-w-md sm:rounded-lg sm:shadow-md md:max-w-lg md:p-12">
<Image
alt={`${config.title} logo`}
width={100}
height={100}
className="relative mx-auto h-12 w-auto"
src={config.logo}
/>
<h1 className="mt-6 text-center font-cal text-3xl">{config.title}</h1>
<p className="mt-2 text-center text-sm">
Turn your markdown into a website in a couple of clicks. <br />
</p>
<div className="mt-4">
<Suspense
fallback={
<div className="my-2 h-10 w-full rounded-md border border-primary-faint bg-primary-faint" />
}
>
<LoginButton />
</Suspense>
<p className="mt-2 text-center text-xs">
By registering, you agree to our
<a
className="font-medium hover:text-primary-subtle"
href={config.termsOfService}
target="_blank"
>
{" "}
Terms of Service.
</a>
</p>
<p className="mt-3 border-t pt-3 text-center text-xs text-primary-subtle">
We use GitHub to securely sync your vault and markdown files with
Flowershow for a seamless experience. No GitHub account? No
problem—sign-up will take just a few seconds.
</p>
</div>
</div>
);
}
Update app/cloud/(auth)/login/page.tsx to render <LoginPanel />.
Step 4: Run test to verify it passes
Run: pnpm test -- components/__tests__/login-panel.test.tsx
Expected: PASS
Step 5: Commit
git add components/auth/login-panel.tsx app/cloud/(auth)/login/page.tsx components/__tests__/login-panel.test.tsx
git commit -m "feat: share login panel"
Task 4: Make LikeRow interactive (test-first)
Files:
- Modify:
components/__tests__/like-row.test.tsx - Modify:
components/like-row.tsx - Modify:
components/layouts/blog.tsx - Modify:
components/layouts/datapackage.tsx - Modify:
components/layouts/__tests__/blog-layout.test.tsx
Step 1: Write the failing tests
Update components/__tests__/like-row.test.tsx:
/**
* @jest-environment jsdom
*/
import { fireEvent, render, screen } from "@testing-library/react";
import LikeRow from "@/components/like-row";
const showModal = jest.fn();
const hideModal = jest.fn();
const mutateToggle = jest.fn();
jest.mock("@/components/modal/provider", () => ({
useModal: () => ({ show: showModal, hide: hideModal }),
}));
jest.mock("next-auth/react", () => ({
useSession: () => ({ status: "unauthenticated", data: null }),
}));
jest.mock("@/trpc/react", () => ({
api: {
like: {
getStatus: {
useQuery: () => ({ data: { count: 3, likedByViewer: false } }),
},
toggle: {
useMutation: () => ({ mutate: mutateToggle, isLoading: false }),
},
},
},
}));
test("opens login modal for unauthenticated users", () => {
render(<LikeRow siteId="site-1" />);
fireEvent.click(screen.getByRole("button", { name: "Like" }));
expect(showModal).toHaveBeenCalledTimes(1);
expect(mutateToggle).not.toHaveBeenCalled();
});
Add a second test in the same file to ensure authenticated users trigger the toggle:
jest.mock("next-auth/react", () => ({
useSession: () => ({ status: "authenticated", data: { user: { id: "u1" } } }),
}));
test("toggles like for authenticated users", () => {
render(<LikeRow siteId="site-1" />);
fireEvent.click(screen.getByRole("button", { name: "Like" }));
expect(mutateToggle).toHaveBeenCalledWith({ siteId: "site-1" });
});
Update components/layouts/__tests__/blog-layout.test.tsx to mock LikeRow (avoid TRPC in layout test):
jest.mock("@/components/like-row", () => () => (
<div data-testid="like-row" />
));
test("renders like row under the title", () => {
render(
<BlogLayout metadata={metadata} siteMetadata={{ id: "site-1" } as any}>
<p>Body</p>
</BlogLayout>,
);
expect(screen.getByTestId("like-row")).toBeInTheDocument();
});
Step 2: Run tests to verify they fail
Run: pnpm test -- components/__tests__/like-row.test.tsx
Expected: FAIL because LikeRow is not interactive and no button exists.
Step 3: Write minimal implementation
Update components/like-row.tsx:
"use client";
import { useEffect, useMemo, useState } from "react";
import { HeartIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { api } from "@/trpc/react";
import { useModal } from "@/components/modal/provider";
import LoginPanel from "@/components/auth/login-panel";
const PENDING_LIKE_STORAGE_KEY = "datahub:pending-like-site";
interface LikeRowProps {
siteId: string;
}
const LikeRow = ({ siteId }: LikeRowProps) => {
const modal = useModal();
const { status } = useSession();
const [pendingLike, setPendingLike] = useState<string | null>(null);
const { data } = api.like.getStatus.useQuery(
{ siteId },
{ enabled: Boolean(siteId) },
);
const toggleMutation = api.like.toggle.useMutation({
onSuccess: () => {
localStorage.removeItem(PENDING_LIKE_STORAGE_KEY);
setPendingLike(null);
modal?.hide();
},
});
const count = data?.count ?? 0;
const likedByViewer = data?.likedByViewer ?? false;
useEffect(() => {
if (typeof window === "undefined") return;
const stored = localStorage.getItem(PENDING_LIKE_STORAGE_KEY);
if (stored) {
setPendingLike(stored);
}
}, []);
useEffect(() => {
if (status !== "authenticated") return;
if (!pendingLike || pendingLike !== siteId) return;
toggleMutation.mutate({ siteId });
}, [pendingLike, siteId, status, toggleMutation]);
const onClick = () => {
if (status !== "authenticated") {
localStorage.setItem(PENDING_LIKE_STORAGE_KEY, siteId);
setPendingLike(siteId);
modal?.show(<LoginPanel />);
return;
}
toggleMutation.mutate({ siteId });
};
return (
<div
className="mt-4 flex items-center gap-2 text-sm text-slate-500"
data-testid="like-row"
>
<button
type="button"
onClick={onClick}
className="flex items-center gap-2"
aria-label="Like"
>
<HeartIcon
className={`h-4 w-4 ${likedByViewer ? "text-red-500" : ""}`}
aria-hidden="true"
/>
<span>{count}</span>
</button>
</div>
);
};
export default LikeRow;
Update components/layouts/blog.tsx and components/layouts/datapackage.tsx to pass siteId={siteMetadata.id}.
Step 4: Run tests to verify they pass
Run: pnpm test -- components/__tests__/like-row.test.tsx
Expected: PASS
Step 5: Commit
git add components/like-row.tsx components/__tests__/like-row.test.tsx components/layouts/blog.tsx components/layouts/datapackage.tsx components/layouts/__tests__/blog-layout.test.tsx
git commit -m "feat: make like row interactive"
Task 5: Verify full test suite
Step 1: Run all tests
Run: pnpm test
Expected: PASS
Step 2: Commit any remaining fixes
git status -sb