From b4ef5bc195923c0f86e4593a60ab436349b5d714 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 23 Jul 2026 20:22:42 +0200 Subject: [PATCH] feat(ads): track and display per-ad view counts for advertisers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advertisers/admins had no way to see how many times an ad was actually shown — only engagement counts (reactions/comments/reposts) existed. Adds Advertisement.adViewCount, a denormalized counter matching the existing engagement-counter pattern. AdCard.tsx fires ads.recordView once per card, the first time it scrolls ≥50% into view (react-intersection-observer's triggerOnce, already a project dependency via FeedList's pagination sentinel) — matches standard "viewable impression" semantics rather than counting on mount (which would also count ads that render off-screen and are never actually seen). Not deduplicated per pet, unlike reactions/reposts — an impression counts every time an ad becomes visible, matching how ad platforms report view counts. Admin ads panel (/p/[secret]/ads) now shows view/reaction/comment/ repost counts inline on every ad row — the actual "results" advertisers want from a placement, previously not surfaced anywhere despite already existing on the Advertisement row for the other three counters. --- prisma/schema.prisma | 5 +++++ src/__tests__/ads.test.ts | 17 +++++++++++++++++ src/app/p/[secret]/ads/page.tsx | 22 +++++++++++++++++++++- src/components/feed/AdCard.tsx | 16 ++++++++++++++-- src/trpc/routers/ads.ts | 27 +++++++++++++++++++++++++-- 5 files changed, 82 insertions(+), 5 deletions(-) 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()} + +
+