diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 98b3591..4358afd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -557,6 +557,11 @@ model Advertisement {
updatedAt DateTime @updatedAt
// Denormalized engagement counters — see Post model comment for rationale.
+ // adViewCount is incremented once per ad-card impression (client fires
+ // ads.recordView the first time the card is ≥50% scrolled into view —
+ // see AdCard.tsx), not deduplicated per pet, matching standard ad-impression
+ // semantics rather than the per-pet-once pattern reactions/reposts use.
+ adViewCount Int @default(0)
adReactionCount Int @default(0)
adCommentCount Int @default(0)
adRepostCount Int @default(0)
diff --git a/src/__tests__/ads.test.ts b/src/__tests__/ads.test.ts
index a7023a2..fae6ff3 100644
--- a/src/__tests__/ads.test.ts
+++ b/src/__tests__/ads.test.ts
@@ -50,6 +50,7 @@ describe("ads.getFeedData", () => {
const { caller, mockPrisma } = await createAdsCaller();
mockPrisma.advertisement.findUnique.mockResolvedValue({
+ adViewCount: 240,
adReactionCount: 12,
adCommentCount: 3,
adRepostCount: 1,
@@ -63,6 +64,7 @@ describe("ads.getFeedData", () => {
expect.objectContaining({ where: { id: "ad-1" } })
);
expect(result).toEqual({
+ viewCount: 240,
reactionCount: 12,
hasReacted: true,
commentCount: 3,
@@ -72,6 +74,21 @@ describe("ads.getFeedData", () => {
});
});
+describe("ads.recordView", () => {
+ it("increments adViewCount for the given ad", async () => {
+ const { caller, mockPrisma } = await createAdsCaller();
+ mockPrisma.advertisement.update.mockResolvedValue({ id: "ad-1", adViewCount: 1 });
+
+ const result = await caller.ads.recordView({ adId: "ad-1" });
+
+ expect(mockPrisma.advertisement.update).toHaveBeenCalledWith({
+ where: { id: "ad-1" },
+ data: { adViewCount: { increment: 1 } },
+ });
+ expect(result).toEqual({ ok: true });
+ });
+});
+
describe("ads.toggleReaction — ownership", () => {
it("throws FORBIDDEN when petId does not belong to the caller (2026-07-21 audit fix)", async () => {
const { caller, mockPrisma } = await createAdsCaller();
diff --git a/src/app/p/[secret]/ads/page.tsx b/src/app/p/[secret]/ads/page.tsx
index a0e5447..9e39655 100644
--- a/src/app/p/[secret]/ads/page.tsx
+++ b/src/app/p/[secret]/ads/page.tsx
@@ -4,7 +4,7 @@ import { useState, useRef, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { getMediaUrl } from "@/lib/media-url";
-import { Plus, Pencil, Trash2, Loader2, Check, X, ImagePlus, ImageOff } from "lucide-react";
+import { Plus, Pencil, Trash2, Loader2, Check, X, ImagePlus, ImageOff, Eye, PawPrint, MessageCircle, Repeat2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -215,6 +215,26 @@ export default function AdminAdsPage() {
)}
+ {/* Results — what advertisers actually want to see */}
+
+
+
+ {ad.adViewCount.toLocaleString()}
+
+
+
+ {ad.adReactionCount.toLocaleString()}
+
+
+
+ {ad.adCommentCount.toLocaleString()}
+
+
+
+ {ad.adRepostCount.toLocaleString()}
+
+
+