feat(ads): track and display per-ad view counts for advertisers

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.
This commit is contained in:
admin committed 2026-07-23 20:22:42 +02:00
1 parent 0e02d3da71
commit b4ef5bc195
5 files changed
+82 -5

No files matched your search

+5
View File
@@ -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)
+17
View File
@@ -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();
+21 -1
View File
@@ -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() {
)}
</div>
{/* Results — what advertisers actually want to see */}
<div className="flex items-center gap-3 shrink-0 text-xs text-zinc-500">
<span className="flex items-center gap-1" title="Views">
<Eye className="h-3.5 w-3.5" />
{ad.adViewCount.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Reactions">
<PawPrint className="h-3.5 w-3.5" />
{ad.adReactionCount.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Comments">
<MessageCircle className="h-3.5 w-3.5" />
{ad.adCommentCount.toLocaleString()}
</span>
<span className="flex items-center gap-1" title="Reposts">
<Repeat2 className="h-3.5 w-3.5" />
{ad.adRepostCount.toLocaleString()}
</span>
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="ghost" className="text-zinc-400 hover:text-zinc-100" onClick={() => openEdit(ad)}>
<Pencil className="h-4 w-4" />
+14 -2
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { ExternalLink, PawPrint, MessageCircle, Repeat2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
@@ -73,6 +74,17 @@ export function AdCard({ ad, activePetId }: AdCardProps) {
});
}, [queryClient, trpc, ad.id, activePetId]);
// ── Impression tracking ─────────────────────────────────────────────────────
// Fires once, the first time the card is at least half-visible in the
// viewport — matches the standard "viewable impression" threshold rather
// than counting ads that only briefly touch the edge of the screen.
const { ref: viewRef, inView } = useInView({ threshold: 0.5, triggerOnce: true });
const recordViewMutation = useMutation(trpc.ads.recordView.mutationOptions());
useEffect(() => {
if (inView) recordViewMutation.mutate({ adId: ad.id });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView, ad.id]);
// ── Reaction ────────────────────────────────────────────────────────────────
const reactionMutation = useMutation(
trpc.ads.toggleReaction.mutationOptions({
@@ -117,7 +129,7 @@ export function AdCard({ ad, activePetId }: AdCardProps) {
return (
<>
<article className="bg-card border border-border rounded-lg overflow-hidden">
<article ref={viewRef} className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 rounded-full bg-orange-500/15 border border-orange-400/30 flex items-center justify-center shrink-0">
+25 -2
View File
@@ -4,14 +4,19 @@ import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
export const adsRouter = router({
// Returns reaction/comment/repost counts + whether the given pet has interacted
// Returns view/reaction/comment/repost counts + whether the given pet has interacted
getFeedData: protectedProcedure
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const [ad, hasReacted, hasReposted] = await Promise.all([
ctx.prisma.advertisement.findUnique({
where: { id: input.adId },
select: { adReactionCount: true, adCommentCount: true, adRepostCount: true },
select: {
adViewCount: true,
adReactionCount: true,
adCommentCount: true,
adRepostCount: true,
},
}),
ctx.prisma.adReaction.findUnique({
where: { adId_petId: { adId: input.adId, petId: input.petId } },
@@ -23,6 +28,7 @@ export const adsRouter = router({
}),
]);
return {
viewCount: ad?.adViewCount ?? 0,
reactionCount: ad?.adReactionCount ?? 0,
hasReacted: !!hasReacted,
commentCount: ad?.adCommentCount ?? 0,
@@ -31,6 +37,23 @@ export const adsRouter = router({
};
}),
/**
* Records one ad-card impression. Called once per card render — from
* AdCard.tsx, when the card first scrolls ≥50% into view (not deduplicated
* per pet, matching standard ad-impression semantics — see schema comment).
* Fire-and-forget from the caller's perspective; failures are non-fatal
* (a missed view increment shouldn't surface as a user-visible error).
*/
recordView: protectedProcedure
.input(z.object({ adId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.advertisement.update({
where: { id: input.adId },
data: { adViewCount: { increment: 1 } },
});
return { ok: true };
}),
toggleReaction: protectedProcedure
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {