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